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:
2026-09-20 01:11:04 +03:00
parent b01eb1f34c
commit 33dc1fec96
19 changed files with 1738 additions and 82 deletions
+112
View File
@@ -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: &currentUser.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 вешает выдачу содержимого файла на корневой роутер: