From 33dc1fec96db2441776b49c870378d40bef3e2c6 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 20 Sep 2026 01:11:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(social):=20=D0=B4=D1=80=D1=83=D0=B7=D1=8C?= =?UTF-8?q?=D1=8F,=20=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B5=20=D0=B1=D0=B5?= =?UTF-8?q?=D1=81=D0=B5=D0=B4=D1=8B,=20=D0=BF=D1=80=D0=B8=D1=81=D1=83?= =?UTF-8?q?=D1=82=D1=81=D1=82=D0=B2=D0=B8=D0=B5,=20=D0=B0=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D1=8B=20=D0=B8=20=D1=87=D0=B0=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=B9=20=D0=BF=D0=BE=D1=8F=D1=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По запросу пользователя (вне очереди 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 у постороннего. --- internal/auth/service.go | 26 +- internal/auth/service_test.go | 16 +- internal/bootstrap/bootstrap_test.go | 22 +- .../migrations/00005_phase4_social.sql | 34 ++ internal/gateway/gateway.go | 19 +- internal/gateway/gateway_test.go | 33 +- internal/gateway/ready.go | 43 ++ internal/server/api_context.go | 30 + internal/server/api_files.go | 112 ++++ internal/server/api_messages.go | 25 +- internal/server/api_social.go | 512 ++++++++++++++++++ internal/server/api_users.go | 58 +- internal/server/server.go | 5 + internal/server/social_test.go | 377 +++++++++++++ internal/store/social.go | 340 ++++++++++++ internal/store/users.go | 24 +- web/src/components/chat/MessageContent.tsx | 96 +++- web/src/pages/LoginPage.tsx | 43 +- web/src/pages/settings/TwoFactorSection.tsx | 5 +- 19 files changed, 1738 insertions(+), 82 deletions(-) create mode 100644 internal/database/migrations/00005_phase4_social.sql create mode 100644 internal/server/api_social.go create mode 100644 internal/server/social_test.go create mode 100644 internal/store/social.go diff --git a/internal/auth/service.go b/internal/auth/service.go index d5fe4a0..ee91edb 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -199,9 +199,8 @@ func (s *Service) Register(ctx context.Context, in RegisterInput) (*store.User, return nil, "", nil, fmt.Errorf("create user: %w", err) } - if err := s.joinMainGuild(ctx, user.ID); err != nil { - s.logger.WarnContext(ctx, "failed to join main guild", slog.Any("error", err)) - } + // Новый пользователь попадает в пустой список серверов: вход только по + // приглашению или созданием своего сервера (решение пользователя, 2026-09-20). token, session, err := s.createSession(ctx, user.ID, in.UserAgent, in.IP) if err != nil { @@ -475,27 +474,6 @@ func (s *Service) createSession(ctx context.Context, userID uint64, userAgent, i return token, session, nil } -// joinMainGuild добавляет нового пользователя на главный сервер с ролью -// «Пользователь» (AGENT.md 7.3). -func (s *Service) joinMainGuild(ctx context.Context, userID uint64) error { - settings, err := s.settings.InstanceSettings(ctx) - if err != nil || settings.MainGuildID == 0 { - return err - } - if _, err := s.store.AddGuildMember(ctx, settings.MainGuildID, userID, ""); err != nil { - return err - } - defaultRole, err := s.store.DefaultRole(ctx, settings.MainGuildID) - if err != nil { - // Роль по умолчанию может отсутствовать (сервер без ролей) — это не мешает - // автовступлению, поэтому ошибку не возвращаем, но фиксируем в логе. - s.logger.WarnContext(ctx, "main guild has no default role", - slog.Uint64("guild_id", settings.MainGuildID), slog.Any("error", err)) - return nil - } - return s.store.AssignRole(ctx, settings.MainGuildID, userID, defaultRole.ID) -} - // checkLockout защищает от перебора: серия неудач блокирует попытки на окно. func (s *Service) checkLockout(ctx context.Context, userID uint64) error { events, err := s.store.ListSecurityEvents(ctx, userID, 20) diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go index 208c4a7..a7e8e84 100644 --- a/internal/auth/service_test.go +++ b/internal/auth/service_test.go @@ -114,10 +114,9 @@ func TestRegisterAndLogin(t *testing.T) { if err != nil { t.Fatalf("Register: %v", err) } - // Главный сервер создаётся установщиком (владелец — уже существующий админ). + // Сервер существует (создан установщиком), но новый пользователь в него + // не попадает: вход только по приглашению (решение пользователя). bootstrapMainGuild(t, st, user.ID) - // Новый пользователь регистрируется после появления главного сервера, - // чтобы проверить автовступление. second, _, _, err := service.Register(ctx, auth.RegisterInput{ Username: "newcomer", Email: "newcomer@example.com", Password: "correct-horse-battery", }) @@ -128,15 +127,8 @@ func TestRegisterAndLogin(t *testing.T) { if err != nil { t.Fatalf("ListGuildsForUser(newcomer): %v", err) } - if len(newcomerGuilds) != 1 || !newcomerGuilds[0].IsMain { - t.Fatalf("newcomer must join the main guild, got %+v", newcomerGuilds) - } - newcomerRoles, err := st.MemberRoles(ctx, newcomerGuilds[0].ID, second.ID) - if err != nil { - t.Fatalf("MemberRoles(newcomer): %v", err) - } - if len(newcomerRoles) != 1 || !newcomerRoles[0].IsDefault { - t.Fatalf("newcomer must receive the default role, got %+v", newcomerRoles) + if len(newcomerGuilds) != 0 { + t.Fatalf("newcomer must start without guilds, got %+v", newcomerGuilds) } if user.ID == 0 || token == "" || session.ID == 0 { t.Fatal("registration must return user, token and session") diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 86925d2..cace646 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -156,7 +156,10 @@ func TestBootstrapIsIdempotent(t *testing.T) { } } -func TestNewUserJoinsMainGuild(t *testing.T) { +// TestNewcomerStartsWithoutGuilds фиксирует решение пользователя: главного +// сервера как точки автовхода нет — новичок начинает с пустого списка, а вход +// в сервер возможен только по приглашению или созданием своего. +func TestNewcomerStartsWithoutGuilds(t *testing.T) { ctx := context.Background() runner, st, authService := newRunner(t) result, err := runner.Run(ctx, bootstrap.Options{Email: "owner@example.com", Password: "correct-horse-battery"}) @@ -174,17 +177,16 @@ func TestNewUserJoinsMainGuild(t *testing.T) { if err != nil { t.Fatalf("ListGuildsForUser: %v", err) } - if len(guilds) != 1 || guilds[0].ID != result.GuildID { - t.Fatalf("newcomer must join the main guild: %+v", guilds) + if len(guilds) != 0 { + t.Fatalf("newcomer must start without guilds: %+v", guilds) } - roles, err := st.MemberRoles(ctx, result.GuildID, user.ID) + + // Сервер установщика остаётся рабочим: владелец в нём с ролью администратора. + ownerRoles, err := st.MemberRoles(ctx, result.GuildID, result.UserID) if err != nil { - t.Fatalf("MemberRoles: %v", err) + t.Fatalf("MemberRoles(owner): %v", err) } - if len(roles) != 1 || !roles[0].IsDefault { - t.Fatalf("newcomer must get the default role: %+v", roles) - } - if !permissions.Permission(roles[0].Permissions).Has(permissions.ViewGuild) { - t.Fatal("default role must allow viewing the guild") + if len(ownerRoles) != 1 || !permissions.Permission(ownerRoles[0].Permissions).Has(permissions.Administrator) { + t.Fatalf("owner must keep the administrator role: %+v", ownerRoles) } } diff --git a/internal/database/migrations/00005_phase4_social.sql b/internal/database/migrations/00005_phase4_social.sql new file mode 100644 index 0000000..333630f --- /dev/null +++ b/internal/database/migrations/00005_phase4_social.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- Фаза 4 (по запросу): друзья, личные беседы, присутствие и часовой пояс. + +CREATE TABLE relationships ( + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- friend | outgoing (заявка отправлена) | incoming (заявка получена) | blocked + type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (user_id, target_id) +); +CREATE INDEX relationships_type_idx ON relationships (user_id, type); +CREATE INDEX relationships_target_idx ON relationships (target_id, type); + +-- Участники личной беседы: канал типа dm без сервера (AGENT.md 7.8). +CREATE TABLE dm_participants ( + channel_id INTEGER NOT NULL REFERENCES channels (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (channel_id, user_id) +); +CREATE INDEX dm_participants_user_idx ON dm_participants (user_id); + +-- Присутствие: когда пользователя видели в последний раз и его часовой пояс +-- для показа времени друзьям. +ALTER TABLE users ADD COLUMN last_seen_at TEXT; +ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT 'Europe/Moscow'; + +-- +goose Down +ALTER TABLE users DROP COLUMN timezone; +ALTER TABLE users DROP COLUMN last_seen_at; +DROP TABLE dm_participants; +DROP TABLE relationships; diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index cd89309..3bed71c 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -90,6 +90,12 @@ type ReadyChannel struct { Slowmode int `json:"slowmode_seconds,omitempty"` CanSend bool `json:"can_send"` CanConnect bool `json:"can_connect"` + // Поля личных бесед (type = dm): собеседник и последнее сообщение. + RecipientID string `json:"recipient_id,omitempty"` + IconFileID string `json:"icon_file_id,omitempty"` + Status string `json:"status,omitempty"` + LastMessageID string `json:"last_message_id,omitempty"` + LastMessageAt string `json:"last_message_at,omitempty"` } type ReadyRole struct { @@ -290,10 +296,17 @@ func (s *Service) dispatchToChannel(ctx context.Context, channelID, exceptUserID } } -// canViewChannel проверяет видимость комнаты для сессии. Без калькулятора -// прав событие доставляется всем: так работают тесты и режим без БД. +// canViewChannel проверяет видимость комнаты для сессии. Для личных бесед +// (без сервера) событие получают только участники (AGENT.md 7.8, 9.7). func (s *Service) canViewChannel(ctx context.Context, guildID, channelID uint64, session *clientSession) bool { - if s.visibility == nil || guildID == 0 { + if guildID == 0 { + participant, err := s.store.IsDMParticipant(ctx, channelID, session.userID) + if err != nil { + return false + } + return participant + } + if s.visibility == nil { return true } instanceAdmin := session.user != nil && session.user.IsInstanceAdmin diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 67d44b5..8898277 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -117,6 +117,31 @@ func send(t *testing.T, conn *websocket.Conn, op int, payload any) { } } +// joinMainGuildForTest добавляет пользователя в главный сервер: в продакшене +// это делает принятие приглашения. +func joinMainGuildForTest(t *testing.T, f *fixture, token string) { + t.Helper() + ctx := context.Background() + user, _, err := f.auth.ResolveSession(ctx, token) + if err != nil { + t.Fatalf("ResolveSession: %v", err) + } + guild, err := f.store.GetMainGuild(ctx) + if err != nil { + t.Fatalf("GetMainGuild: %v", err) + } + defaultRole, err := f.store.DefaultRole(ctx, guild.ID) + if err != nil { + t.Fatalf("DefaultRole: %v", err) + } + if _, err := f.store.AddGuildMember(ctx, guild.ID, user.ID, ""); err != nil { + t.Fatalf("AddGuildMember: %v", err) + } + if err := f.store.AssignRole(ctx, guild.ID, user.ID, defaultRole.ID); err != nil { + t.Fatalf("AssignRole: %v", err) + } +} + func registerUser(t *testing.T, f *fixture, username, email string) string { t.Helper() _, token, _, err := f.auth.Register(context.Background(), auth.RegisterInput{ @@ -131,6 +156,8 @@ func registerUser(t *testing.T, f *fixture, username, email string) string { func TestHelloAndReady(t *testing.T) { f := newFixture(t) token := registerUser(t, f, "gateway_user", "gateway@example.com") + // Автовступления больше нет: в сервер вступаем явно, как по приглашению. + joinMainGuildForTest(t, f, token) conn, hello := f.dial(t) var helloPayload struct { @@ -164,12 +191,9 @@ func TestHelloAndReady(t *testing.T) { t.Fatalf("READY user = %q", snapshot.User.Username) } if len(snapshot.Guilds) != 1 { - t.Fatalf("new user must see exactly the main guild, got %d", len(snapshot.Guilds)) + t.Fatalf("user must see the guild they joined, got %d", len(snapshot.Guilds)) } guild := snapshot.Guilds[0] - if !guild.IsMain { - t.Fatal("the main guild must be marked is_main") - } if len(guild.Roles) != 2 { t.Fatalf("main guild roles = %d, want 2", len(guild.Roles)) } @@ -216,6 +240,7 @@ func TestReadyHidesPrivateChannels(t *testing.T) { } token := registerUser(t, f, "private_user", "private@example.com") + joinMainGuildForTest(t, f, token) conn, _ := f.dial(t) send(t, conn, gateway.OpIdentify, map[string]any{"token": token}) ready := readEnvelope(t, conn) diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index addef14..ddbcd50 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -76,6 +76,34 @@ func (s *Snapshot) Build(ctx context.Context, user *store.User) (*Ready, error) }) } + // Личные беседы: попадают в отдельный список, чтобы не смешиваться с + // серверами в интерфейсе (AGENT.md 7.8). + dmChannels, err := s.store.ListDMChannels(ctx, user.ID) + if err != nil { + return nil, fmt.Errorf("list direct channels: %w", err) + } + for _, summary := range dmChannels { + channel := ReadyChannel{ + ID: formatID(summary.Channel.ID), + Type: string(store.ChannelDM), + Name: summary.RecipientName, + CanSend: true, + CanConnect: false, + RecipientID: formatID(summary.RecipientID), + Status: visibleStatus(summary.Status, summary.LastSeenAt), + } + if summary.AvatarFileID != nil { + channel.IconFileID = formatID(*summary.AvatarFileID) + } + if summary.LastMessageID != 0 { + channel.LastMessageID = formatID(summary.LastMessageID) + } + if summary.LastMessageAt != nil { + channel.LastMessageAt = summary.LastMessageAt.UTC().Format(time.RFC3339) + } + ready.DMChannels = append(ready.DMChannels, channel) + } + guilds, err := s.store.ListGuildsForUser(ctx, user.ID) if err != nil { return nil, fmt.Errorf("list user guilds: %w", err) @@ -263,6 +291,21 @@ func (p *permissionSource) MemberTimeout(ctx context.Context, guildID, userID ui return member.TimeoutUntil.After(time.Now().UTC()), nil } +// visibleStatus вычисляет статус, который видят другие: «невидимка» выглядит +// как офлайн, как и пользователь без активности дольше двух минут. +func visibleStatus(status string, lastSeen *time.Time) string { + if status == "invisible" { + return "offline" + } + if status == "" { + status = "online" + } + if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute { + return "offline" + } + return status +} + func formatID(id uint64) string { if id == 0 { return "" diff --git a/internal/server/api_context.go b/internal/server/api_context.go index 7454954..8c73dbd 100644 --- a/internal/server/api_context.go +++ b/internal/server/api_context.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" "strconv" + "time" "github.com/danielgtaylor/huma/v2" @@ -39,11 +41,39 @@ func (s *Server) sessionContext(next http.Handler) http.Handler { next.ServeHTTP(w, r) return } + s.touchPresence(user.ID) ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session}) next.ServeHTTP(w, r.WithContext(ctx)) }) } +// touchPresence обновляет время последней активности пользователя, но не чаще +// раза в минуту: значение нужно списку друзей (статус и «был в сети»). +func (s *Server) touchPresence(userID uint64) { + s.presenceMu.Lock() + last, ok := s.presence[userID] + now := time.Now() + if ok && now.Sub(last) < time.Minute { + s.presenceMu.Unlock() + return + } + if len(s.presence) > 8192 { + for id, at := range s.presence { + if now.Sub(at) > time.Hour { + delete(s.presence, id) + } + } + } + s.presence[userID] = now + s.presenceMu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := s.store.TouchLastSeen(ctx, userID); err != nil { + s.logger.DebugContext(ctx, "failed to update last seen", slog.Any("error", err)) + } +} + // sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop). func sessionToken(r *http.Request) string { if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { diff --git a/internal/server/api_files.go b/internal/server/api_files.go index daf0918..9eb4bfc 100644 --- a/internal/server/api_files.go +++ b/internal/server/api_files.go @@ -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 вешает выдачу содержимого файла на корневой роутер: diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 20e629c..4bf9e76 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -539,8 +539,29 @@ func (s *Server) requireChannelPermission(ctx context.Context, rawChannelID stri return 0, permissions.Resolved{}, nil, humaError(err) } if channel.GuildID == nil { - // Личные комнаты появятся в Фазе 4: сейчас их нет. - return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + // Личная беседа: участник получает права на переписку, посторонний + // не видит канал вовсе (AGENT.md 7.8). + if channel.Type != store.ChannelDM { + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + } + participant, err := s.store.IsDMParticipant(ctx, channelID, user.ID) + if err != nil { + return 0, permissions.Resolved{}, nil, humaError(err) + } + if !participant { + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + } + dmPermissions := permissions.ViewChannel | permissions.SendMessages | + permissions.ReadMessageHistory | permissions.AttachFiles | permissions.AddReactions + resolved := permissions.Resolved{ + Guild: dmPermissions, + Channel: dmPermissions, + IsMember: true, + } + if !resolved.Can(permission) { + return 0, permissions.Resolved{}, nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") + } + return channelID, resolved, channel, nil } resolved, err := s.perms.Channel(ctx, *channel.GuildID, channelID, user.ID, user.IsInstanceAdmin) if err != nil { diff --git a/internal/server/api_social.go b/internal/server/api_social.go new file mode 100644 index 0000000..0383772 --- /dev/null +++ b/internal/server/api_social.go @@ -0,0 +1,512 @@ +package server + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/store" +) + +// relationshipUser — профиль собеседника вместе с типом связи (AGENT.md 7.8). +type relationshipUser struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + Status string `json:"status"` + CustomStatus string `json:"custom_status,omitempty"` + IsInstanceAdmin bool `json:"is_instance_admin"` + Badges []string `json:"badges"` + // VisibleStatus — статус, который видят другие: «невидимка» выглядит как + // офлайн, а без активности дольше двух минут показываем офлайн. + VisibleStatus string `json:"visible_status"` + LastSeenAt string `json:"last_seen_at,omitempty"` + Timezone string `json:"timezone"` + // Relationship: friend | incoming | outgoing | blocked | none + Relationship string `json:"relationship"` +} + +type relationshipListOutput struct { + Body struct { + Friends []relationshipUser `json:"friends"` + Incoming []relationshipUser `json:"incoming"` + Outgoing []relationshipUser `json:"outgoing"` + Blocked []relationshipUser `json:"blocked"` + } +} + +type userSearchOutput struct { + Body struct { + Users []relationshipUser `json:"users"` + } +} + +type dmChannelPayload struct { + ID string `json:"id"` + Type string `json:"type"` + CanSend bool `json:"can_send"` + CanView bool `json:"can_view"` + Recipient struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + Status string `json:"status"` + VisibleStatus string `json:"visible_status"` + LastSeenAt string `json:"last_seen_at,omitempty"` + Timezone string `json:"timezone"` + } `json:"recipient"` + LastMessageID string `json:"last_message_id,omitempty"` + LastMessageAt string `json:"last_message_at,omitempty"` +} + +type dmChannelListOutput struct { + Body struct { + Channels []dmChannelPayload `json:"channels"` + } +} + +type dmChannelOutput struct { + Body struct { + Channel dmChannelPayload `json:"channel"` + } +} + +// registerSocialRoutes описывает друзей, поиск пользователей и личные беседы +// (AGENT.md 7.8; раздел запрошен пользователем вне очереди). +func (s *Server) registerSocialRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "searchUsers", + Method: http.MethodGet, + Path: "/users/search", + Summary: "Поиск пользователей по логину", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + Query string `query:"q" minLength:"2" maxLength:"32"` + Limit int `query:"limit" default:"20" minimum:"1" maximum:"50"` + }, + ) (*userSearchOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + users, err := s.store.SearchUsersByUsername(ctx, strings.TrimSpace(input.Query), user.ID, input.Limit) + if err != nil { + return nil, humaError(err) + } + output := &userSearchOutput{} + output.Body.Users = make([]relationshipUser, 0, len(users)) + for i := range users { + payload, err := s.relationshipUser(ctx, user.ID, &users[i]) + if err != nil { + return nil, err + } + output.Body.Users = append(output.Body.Users, payload) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listRelationships", + Method: http.MethodGet, + Path: "/users/@me/relationships", + Summary: "Друзья, входящие и исходящие заявки", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*relationshipListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + output := &relationshipListOutput{} + output.Body.Friends, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipFriend) + if err != nil { + return nil, err + } + if output.Body.Incoming, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipIncoming); err != nil { + return nil, err + } + if output.Body.Outgoing, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipOutgoing); err != nil { + return nil, err + } + if output.Body.Blocked, err = s.relationshipProfiles(ctx, user.ID, store.RelationshipBlocked); err != nil { + return nil, err + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "sendFriendRequest", + Method: http.MethodPost, + Path: "/users/@me/relationships", + Summary: "Отправить заявку в друзья по логину", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + Body struct { + Username string `json:"username,omitempty" maxLength:"32"` + UserID string `json:"user_id,omitempty"` + } + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + target, err := s.relationshipTarget(ctx, input.Body.UserID, input.Body.Username, user.ID) + if err != nil { + return nil, err + } + // Если встречная заявка уже есть — сразу становимся друзьями. + reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID) + switch { + case err == nil && reverse.Type == store.RelationshipIncoming: + if err := s.makeFriends(ctx, user.ID, target.ID); err != nil { + return nil, err + } + return newOKOutput(), nil + case err == nil && reverse.Type == store.RelationshipFriend: + return newOKOutput(), nil + case err != nil && !errors.Is(err, store.ErrNotFound): + return nil, humaError(err) + } + if err := s.store.SetRelationship(ctx, user.ID, target.ID, store.RelationshipOutgoing); err != nil { + return nil, humaError(err) + } + if err := s.store.SetRelationship(ctx, target.ID, user.ID, store.RelationshipIncoming); err != nil { + return nil, humaError(err) + } + s.dispatchRelationshipUpdate(user.ID, target.ID) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "acceptFriendRequest", + Method: http.MethodPost, + Path: "/users/@me/relationships/{user_id}/accept", + Summary: "Принять заявку в друзья", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + incoming, err := s.store.GetRelationship(ctx, user.ID, targetID) + if err != nil { + return nil, humaError(err) + } + if incoming.Type != store.RelationshipIncoming { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "no incoming friend request from this user") + } + if err := s.makeFriends(ctx, user.ID, targetID); err != nil { + return nil, err + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "removeRelationship", + Method: http.MethodDelete, + Path: "/users/@me/relationships/{user_id}", + Summary: "Удалить из друзей, отклонить или отменить заявку", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if err := s.store.RemoveRelationship(ctx, user.ID, targetID); err != nil { + return nil, humaError(err) + } + if err := s.store.RemoveRelationship(ctx, targetID, user.ID); err != nil { + return nil, humaError(err) + } + s.dispatchRelationshipUpdate(user.ID, targetID) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "openDirectChannel", + Method: http.MethodPost, + Path: "/users/@me/channels", + Summary: "Открыть личную беседу с пользователем", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, input *struct { + Body struct { + RecipientID string `json:"recipient_id" minLength:"1"` + } + }, + ) (*dmChannelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + recipientID, err := parseID("recipient_id", input.Body.RecipientID) + if err != nil { + return nil, err + } + if recipientID == user.ID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot open a direct channel with yourself") + } + recipient, err := s.store.GetUser(ctx, recipientID) + if err != nil { + return nil, humaError(err) + } + channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID) + if errors.Is(err, store.ErrNotFound) { + channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID) + if err != nil { + return nil, humaError(err) + } + // Оба участника сразу видят беседу в списке (AGENT.md 8.3). + for _, participant := range []uint64{user.ID, recipientID} { + if s.gateway != nil { + s.gateway.SendToUser(participant, "DM_CHANNEL_CREATE", map[string]any{ + "channel_id": formatSnowflake(channel.ID), + }) + } + } + } else if err != nil { + return nil, humaError(err) + } + + summary := store.DMChannelSummary{ + Channel: *channel, + RecipientID: recipient.ID, + RecipientName: recipient.DisplayName, + RecipientLogin: recipient.Username, + AvatarFileID: recipient.AvatarFileID, + Status: recipient.Status, + LastSeenAt: recipient.LastSeenAt, + Timezone: recipient.Timezone, + } + output := &dmChannelOutput{} + output.Body.Channel = s.dmChannelPayload(summary) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listDirectChannels", + Method: http.MethodGet, + Path: "/users/@me/channels", + Summary: "Личные беседы пользователя", + Tags: []string{"Friends"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*dmChannelListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + channels, err := s.store.ListDMChannels(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + output := &dmChannelListOutput{} + output.Body.Channels = make([]dmChannelPayload, 0, len(channels)) + for _, summary := range channels { + output.Body.Channels = append(output.Body.Channels, s.dmChannelPayload(summary)) + } + return output, nil + }) +} + +// relationshipTarget находит пользователя по id или логину. +func (s *Server) relationshipTarget(ctx context.Context, rawID, username string, selfID uint64) (*store.User, error) { + if rawID != "" { + targetID, err := parseID("user_id", rawID) + if err != nil { + return nil, err + } + if targetID == selfID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend") + } + target, err := s.store.GetUser(ctx, targetID) + if err != nil { + return nil, humaError(err) + } + return target, nil + } + username = strings.TrimSpace(username) + if username == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "username or user_id is required") + } + target, err := s.store.GetUserByUsername(ctx, username) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, humaErrorStatus(http.StatusNotFound, "user.not_found", "пользователь с таким логином не найден") + } + return nil, humaError(err) + } + if target.ID == selfID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "cannot add yourself as a friend") + } + return target, nil +} + +// makeFriends делает пользователей друзьями в обе стороны. +func (s *Server) makeFriends(ctx context.Context, firstID, secondID uint64) error { + if err := s.store.SetRelationship(ctx, firstID, secondID, store.RelationshipFriend); err != nil { + return humaError(err) + } + if err := s.store.SetRelationship(ctx, secondID, firstID, store.RelationshipFriend); err != nil { + return humaError(err) + } + s.dispatchRelationshipUpdate(firstID, secondID) + return nil +} + +// dispatchRelationshipUpdate уведомляет обоих участников об изменении связи. +func (s *Server) dispatchRelationshipUpdate(firstID, secondID uint64) { + if s.gateway == nil { + return + } + for _, userID := range []uint64{firstID, secondID} { + s.gateway.SendToUser(userID, "RELATIONSHIP_UPDATE", map[string]any{ + "user_id": formatSnowflake(userID), + }) + } +} + +// relationshipProfiles собирает список связей указанного типа. +func (s *Server) relationshipProfiles(ctx context.Context, userID uint64, kind store.RelationshipType) ([]relationshipUser, error) { + profiles, err := s.store.ListRelationships(ctx, userID, kind) + if err != nil { + return nil, humaError(err) + } + result := make([]relationshipUser, 0, len(profiles)) + for i := range profiles { + result = append(result, s.relationshipPayload(userID, &profiles[i], string(kind))) + } + return result, nil +} + +// relationshipUser собирает профиль пользователя со связью (для поиска). +func (s *Server) relationshipUser(ctx context.Context, viewerID uint64, user *store.User) (relationshipUser, error) { + kind := "none" + relation, err := s.store.GetRelationship(ctx, viewerID, user.ID) + switch { + case err == nil: + kind = string(relation.Type) + case errors.Is(err, store.ErrNotFound): + default: + return relationshipUser{}, humaError(err) + } + profile := store.RelationshipProfile{ + UserID: user.ID, + Username: user.Username, + DisplayName: user.DisplayName, + AvatarFileID: user.AvatarFileID, + Status: user.Status, + CustomStatus: user.CustomStatus, + Badges: user.Badges, + IsInstanceAdmin: user.IsInstanceAdmin, + LastSeenAt: user.LastSeenAt, + Timezone: user.Timezone, + } + return s.relationshipPayload(viewerID, &profile, kind), nil +} + +// relationshipPayload переводит профиль в формат API, вычисляя видимый статус. +func (s *Server) relationshipPayload(_ uint64, profile *store.RelationshipProfile, kind string) relationshipUser { + payload := relationshipUser{ + UserID: formatSnowflake(profile.UserID), + Username: profile.Username, + DisplayName: profile.DisplayName, + Status: profile.Status, + CustomStatus: profile.CustomStatus, + IsInstanceAdmin: profile.IsInstanceAdmin, + Badges: profile.Badges, + VisibleStatus: visibleStatus(profile.Status, profile.LastSeenAt), + Timezone: profile.Timezone, + Relationship: kind, + } + if payload.Badges == nil { + payload.Badges = []string{} + } + if payload.Timezone == "" { + payload.Timezone = "Europe/Moscow" + } + if profile.AvatarFileID != nil { + payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID) + } + if profile.LastSeenAt != nil { + payload.LastSeenAt = profile.LastSeenAt.UTC().Format(timeLayout) + } + return payload +} + +// dmChannelPayload собирает личную беседу для API. +func (s *Server) dmChannelPayload(summary store.DMChannelSummary) dmChannelPayload { + payload := dmChannelPayload{ + ID: formatSnowflake(summary.Channel.ID), + Type: string(store.ChannelDM), + CanSend: true, + CanView: true, + } + payload.Recipient.UserID = formatSnowflake(summary.RecipientID) + payload.Recipient.Username = summary.RecipientLogin + payload.Recipient.DisplayName = summary.RecipientName + payload.Recipient.Status = summary.Status + payload.Recipient.Timezone = summary.Timezone + if payload.Recipient.Timezone == "" { + payload.Recipient.Timezone = "Europe/Moscow" + } + if summary.LastMessageAt != nil { + payload.LastMessageAt = summary.LastMessageAt.UTC().Format(timeLayout) + } + if summary.LastMessageID != 0 { + payload.LastMessageID = formatSnowflake(summary.LastMessageID) + } + if summary.AvatarFileID != nil { + payload.Recipient.AvatarFileID = formatSnowflake(*summary.AvatarFileID) + } + if summary.LastSeenAt != nil { + payload.Recipient.LastSeenAt = summary.LastSeenAt.UTC().Format(timeLayout) + } + payload.Recipient.VisibleStatus = visibleStatus(summary.Status, summary.LastSeenAt) + return payload +} + +// timeLayout — единый формат времени в API. +const timeLayout = "2006-01-02T15:04:05Z07:00" + +// visibleStatus вычисляет статус, который видят другие пользователи: +// «невидимка» отображается как офлайн, как и давно неактивный пользователь +// (AGENT.md 7.2). +func visibleStatus(status string, lastSeen *time.Time) string { + if status == "invisible" { + return "offline" + } + if status == "" { + status = "online" + } + if lastSeen != nil && time.Since(*lastSeen) > 2*time.Minute { + return "offline" + } + return status +} diff --git a/internal/server/api_users.go b/internal/server/api_users.go index 3f09ca5..47f799f 100644 --- a/internal/server/api_users.go +++ b/internal/server/api_users.go @@ -2,6 +2,7 @@ package server import ( "context" + "log/slog" "net/http" "strings" "time" @@ -30,6 +31,11 @@ type profilePayload struct { // TOTPEnabled — включена ли 2FA: клиенту нужно знать, требовать ли код // при step-up и показывать ли QR при настройке (AGENT.md 7.1). TOTPEnabled bool `json:"totp_enabled"` + // Timezone — часовой пояс пользователя (по умолчанию МСК): по нему клиент + // показывает время последнего входа друзьям. + Timezone string `json:"timezone"` + // LastSeenAt — когда пользователя видели последний раз (для друзей). + LastSeenAt string `json:"last_seen_at,omitempty"` } // profileFromUser собирает профиль; состояние 2FA читается из сервиса @@ -68,6 +74,13 @@ func profileFromUser(user *store.User, includePrivate bool) profilePayload { if includePrivate { payload.Locale = user.Locale } + payload.Timezone = user.Timezone + if payload.Timezone == "" { + payload.Timezone = "Europe/Moscow" + } + if user.LastSeenAt != nil { + payload.LastSeenAt = user.LastSeenAt.UTC().Format(time.RFC3339) + } return payload } @@ -103,6 +116,7 @@ type updateProfileInput struct { CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"` CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"` Locale *string `json:"locale,omitempty" enum:"ru,en"` + Timezone *string `json:"timezone,omitempty" maxLength:"64"` } } @@ -194,6 +208,11 @@ func (s *Server) registerUserRoutes(api huma.API) { CustomStatusEmoji: input.Body.CustomStatusEmoji, Locale: input.Body.Locale, } + if input.Body.Timezone != nil { + if _, err := time.LoadLocation(*input.Body.Timezone); err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown timezone") + } + } if input.Body.DisplayName != nil { trimmed := strings.TrimSpace(*input.Body.DisplayName) if trimmed == "" { @@ -205,8 +224,21 @@ func (s *Server) registerUserRoutes(api huma.API) { if err != nil { return nil, humaError(err) } - // Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3). + if input.Body.Timezone != nil { + if err := s.store.SetTimezone(ctx, user.ID, *input.Body.Timezone); err != nil { + return nil, humaError(err) + } + updated, err = s.store.GetUser(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + } + // Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3), + // а друзья — обновление присутствия (AGENT.md 7.16). s.dispatchUserUpdate(updated) + if input.Body.Status != nil || input.Body.CustomStatus != nil { + s.dispatchPresenceUpdate(ctx, updated) + } output := &meOutput{} output.Body.User = s.profileFromUser(ctx, updated, true) return output, nil @@ -392,6 +424,30 @@ func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uin return summary, nil } +// dispatchPresenceUpdate рассылает статус пользователя его друзьям. +func (s *Server) dispatchPresenceUpdate(ctx context.Context, user *store.User) { + if s.gateway == nil { + return + } + friends, err := s.store.ListRelationships(ctx, user.ID, store.RelationshipFriend) + if err != nil { + s.logger.WarnContext(ctx, "failed to list friends for presence", slog.Any("error", err)) + return + } + payload := map[string]any{ + "user_id": formatSnowflake(user.ID), + "status": user.Status, + "visible_status": visibleStatus(user.Status, user.LastSeenAt), + "custom_status": user.CustomStatus, + } + if user.LastSeenAt != nil { + payload["last_seen_at"] = user.LastSeenAt.UTC().Format(timeLayout) + } + for _, friend := range friends { + s.gateway.SendToUser(friend.UserID, "PRESENCE_UPDATE", payload) + } +} + // dispatchUserUpdate рассылает обновление профиля во все сессии пользователя. func (s *Server) dispatchUserUpdate(user *store.User) { if s.gateway == nil { diff --git a/internal/server/server.go b/internal/server/server.go index e4322dc..77b8f08 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -57,6 +57,9 @@ type Server struct { // отправки; словарь ограничен по размеру (AGENT.md 7.5). slowmodeMu sync.Mutex slowmode map[string]time.Time + // presence — время последнего обновления last_seen по пользователю. + presenceMu sync.Mutex + presence map[uint64]time.Time logger *slog.Logger http *http.Server static *staticHandler @@ -82,6 +85,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se searchLimiter: httpx.NewRateLimiter(10, 10), inviteLimiter: httpx.NewRateLimiterWindow(10, 24*time.Hour, 10), slowmode: map[string]time.Time{}, + presence: map[uint64]time.Time{}, } switch { case deps.Permissions != nil: @@ -105,6 +109,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se s.registerMessageRoutes(s.api) s.registerInviteRoutes(s.api) s.registerFileRoutes(s.api, apiRouter) + s.registerSocialRoutes(s.api) } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) diff --git a/internal/server/social_test.go b/internal/server/social_test.go new file mode 100644 index 0000000..32669ba --- /dev/null +++ b/internal/server/social_test.go @@ -0,0 +1,377 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "testing" + "time" +) + +// TestFriendRequestFlow проверяет заявки в друзья и поиск пользователей. +func TestFriendRequestFlow(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_friend", "alice-friend@example.com") + bobCookie := registerAndLogin(t, srv, "bob_friend", "bob-friend@example.com") + bob, err := srv.auth.UserByEmail(t.Context(), "bob-friend@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + + // Поиск по логину находит Боба и показывает состояние связи. + search := doJSON(t, srv, http.MethodGet, "/api/v1/users/search?q=bob_fr", "", aliceCookie) + if search.Code != http.StatusOK { + t.Fatalf("search = %d, body = %s", search.Code, search.Body.String()) + } + found := decodeResponse[struct { + Users []struct { + Username string `json:"username"` + Relationship string `json:"relationship"` + } `json:"users"` + }](t, search) + if len(found.Users) != 1 || found.Users[0].Username != "bob_friend" || found.Users[0].Relationship != "none" { + t.Fatalf("search results = %+v", found.Users) + } + + // Алиса отправляет заявку: у неё outgoing, у Боба incoming. + sent := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_friend"}`, aliceCookie) + if sent.Code != http.StatusOK { + t.Fatalf("send request = %d, body = %s", sent.Code, sent.Body.String()) + } + bobList := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", bobCookie) + bobPayload := decodeResponse[struct { + Incoming []struct { + Username string `json:"username"` + Relationship string `json:"relationship"` + } `json:"incoming"` + Friends []struct { + Username string `json:"username"` + } `json:"friends"` + }](t, bobList) + if len(bobPayload.Incoming) != 1 || bobPayload.Incoming[0].Username != "alice_friend" { + t.Fatalf("incoming = %+v", bobPayload.Incoming) + } + if len(bobPayload.Friends) != 0 { + t.Fatalf("friends must be empty before accept: %+v", bobPayload.Friends) + } + + // Боб принимает: оба видят друг друга в друзьях. + accept := doJSON(t, srv, http.MethodPost, + "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-friend@example.com"))+"/accept", + "", bobCookie) + if accept.Code != http.StatusOK { + t.Fatalf("accept = %d, body = %s", accept.Code, accept.Body.String()) + } + aliceFriends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) + payload := decodeResponse[struct { + Friends []struct { + Username string `json:"username"` + VisibleStatus string `json:"visible_status"` + Timezone string `json:"timezone"` + } `json:"friends"` + }](t, aliceFriends) + if len(payload.Friends) != 1 || payload.Friends[0].Username != "bob_friend" { + t.Fatalf("friends = %+v", payload.Friends) + } + if payload.Friends[0].Timezone != "Europe/Moscow" { + t.Fatalf("default timezone = %q, want Europe/Moscow", payload.Friends[0].Timezone) + } + + // Повторная заявка ничего не ломает. + repeat := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"user_id":"`+formatSnowflake(bob.ID)+`"}`, aliceCookie) + if repeat.Code != http.StatusOK { + t.Fatalf("repeat request = %d, body = %s", repeat.Code, repeat.Body.String()) + } +} + +// TestDirectChannelMessaging проверяет личные беседы: доступ только участникам. +func TestDirectChannelMessaging(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_dm", "alice-dm@example.com") + bobCookie := registerAndLogin(t, srv, "bob_dm", "bob-dm@example.com") + strangerCookie := registerAndLogin(t, srv, "eve_dm", "eve-dm@example.com") + bobID := formatSnowflake(mustUserID(t, srv, "bob-dm@example.com")) + + // Алиса открывает беседу с Бобом. + opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", + `{"recipient_id":"`+bobID+`"}`, aliceCookie) + if opened.Code != http.StatusOK { + t.Fatalf("open dm = %d, body = %s", opened.Code, opened.Body.String()) + } + channel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + Type string `json:"type"` + CanSend bool `json:"can_send"` + Recipient struct { + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + VisibleStatus string `json:"visible_status"` + } `json:"recipient"` + } `json:"channel"` + }](t, opened) + if channel.Channel.Type != "dm" || !channel.Channel.CanSend { + t.Fatalf("unexpected dm channel: %+v", channel.Channel) + } + if channel.Channel.Recipient.UserID != bobID { + t.Fatalf("recipient = %q, want %q", channel.Channel.Recipient.UserID, bobID) + } + + // Повторное открытие возвращает ту же беседу. + again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", + `{"recipient_id":"`+bobID+`"}`, aliceCookie) + reopened := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, again) + if reopened.Channel.ID != channel.Channel.ID { + t.Fatalf("dm channel changed: %s → %s", channel.Channel.ID, reopened.Channel.ID) + } + + // Сообщение из беседы и список бесед у обоих участников. + sent := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", + `{"content":"привет в личке"}`, aliceCookie) + if sent.Code != http.StatusOK { + t.Fatalf("dm message = %d, body = %s", sent.Code, sent.Body.String()) + } + list := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie) + channels := decodeResponse[struct { + Channels []struct { + ID string `json:"id"` + LastMessageID string `json:"last_message_id"` + } `json:"channels"` + }](t, list) + if len(channels.Channels) != 1 || channels.Channels[0].ID != channel.Channel.ID || channels.Channels[0].LastMessageID == "" { + t.Fatalf("dm list = %+v", channels.Channels) + } + + // Посторонний не видит беседу и не может писать. + forbidden := doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channel.Channel.ID+"/messages", "", strangerCookie) + if forbidden.Code != http.StatusNotFound { + t.Fatalf("stranger dm read = %d, want 404", forbidden.Code) + } + forbiddenSend := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", + `{"content":"я тут лишний"}`, strangerCookie) + if forbiddenSend.Code != http.StatusNotFound { + t.Fatalf("stranger dm write = %d, want 404", forbiddenSend.Code) + } +} + +// TestInvisibleStatusHiddenFromFriends проверяет, что «невидимка» виден как офлайн. +func TestInvisibleStatusHiddenFromFriends(t *testing.T) { + srv, _ := newTestServer(t) + aliceCookie := registerAndLogin(t, srv, "alice_inv", "alice-inv@example.com") + bobCookie := registerAndLogin(t, srv, "bob_inv", "bob-inv@example.com") + + doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_inv"}`, aliceCookie) + accept := doJSON(t, srv, http.MethodPost, + "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-inv@example.com"))+"/accept", "", bobCookie) + if accept.Code != http.StatusOK { + t.Fatalf("accept = %d", accept.Code) + } + + // Боб уходит в невидимку. + hidden := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"invisible"}`, bobCookie) + if hidden.Code != http.StatusOK { + t.Fatalf("set invisible = %d, body = %s", hidden.Code, hidden.Body.String()) + } + friends := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) + payload := decodeResponse[struct { + Friends []struct { + Username string `json:"username"` + Status string `json:"status"` + VisibleStatus string `json:"visible_status"` + } `json:"friends"` + }](t, friends) + if len(payload.Friends) != 1 { + t.Fatalf("friends = %+v", payload.Friends) + } + if payload.Friends[0].VisibleStatus != "offline" { + t.Fatalf("invisible user must look offline, got %q", payload.Friends[0].VisibleStatus) + } + + // Обычный статус виден как есть. + doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"idle"}`, bobCookie) + friends = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", aliceCookie) + payload = decodeResponse[struct { + Friends []struct { + Username string `json:"username"` + Status string `json:"status"` + VisibleStatus string `json:"visible_status"` + } `json:"friends"` + }](t, friends) + if payload.Friends[0].VisibleStatus != "idle" { + t.Fatalf("idle status must be visible, got %q", payload.Friends[0].VisibleStatus) + } +} + +// TestTimezoneAndAvatarUpdate проверяет часовой пояс и загрузку аватара. +func TestTimezoneAndAvatarUpdate(t *testing.T) { + srv, _ := newTestServer(t) + httpServer := httptest.NewServer(srv.Handler()) + t.Cleanup(httpServer.Close) + cookie := registerAndLogin(t, srv, "tz_user", "tz-user@example.com") + + // Часовой пояс по умолчанию — МСК, можно сменить. + type mePayload struct { + User struct { + Timezone string `json:"timezone"` + AvatarFileID string `json:"avatar_file_id"` + } `json:"user"` + } + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) + profile := decodeResponse[mePayload](t, me) + if profile.User.Timezone != "Europe/Moscow" { + t.Fatalf("default timezone = %q", profile.User.Timezone) + } + updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Europe/Berlin"}`, cookie) + if updated.Code != http.StatusOK { + t.Fatalf("set timezone = %d, body = %s", updated.Code, updated.Body.String()) + } + after := decodeResponse[mePayload](t, updated) + if after.User.Timezone != "Europe/Berlin" { + t.Fatalf("timezone = %q, want Europe/Berlin", after.User.Timezone) + } + bad := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"timezone":"Марс/Олимп"}`, cookie) + if bad.Code != http.StatusUnprocessableEntity { + t.Fatalf("invalid timezone = %d, want 422", bad.Code) + } + + // Аватар: загрузка картинки и удаление. + fileID := uploadAvatar(t, httpServer, cookie, "me.png", + append([]byte("\x89PNG\r\n\x1a\n"), []byte("аватар-данные")...)) + if fileID == "" { + t.Fatal("avatar upload must return a file id") + } + me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) + profile = decodeResponse[mePayload](t, me) + if profile.User.AvatarFileID != fileID { + t.Fatalf("avatar_file_id = %q, want %q", profile.User.AvatarFileID, fileID) + } + + removed := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/avatar", "", cookie) + if removed.Code != http.StatusOK { + t.Fatalf("delete avatar = %d, body = %s", removed.Code, removed.Body.String()) + } + me = doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) + profile = decodeResponse[mePayload](t, me) + if profile.User.AvatarFileID != "" { + t.Fatalf("avatar must be cleared, got %q", profile.User.AvatarFileID) + } +} + +// mustUserID находит пользователя по email и возвращает его идентификатор. +func mustUserID(t *testing.T, srv *Server, email string) uint64 { + t.Helper() + user, err := srv.auth.UserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("UserByEmail(%s): %v", email, err) + } + return user.ID +} + +// TestPresenceUpdateReachesFriends проверяет, что смена статуса видна другу +// через Gateway без перезагрузки (AGENT.md 7.16). +func TestPresenceUpdateReachesFriends(t *testing.T) { + srv, _ := newTestServer(t) + httpServer := httptest.NewServer(srv.Handler()) + t.Cleanup(httpServer.Close) + + aliceCookie := registerAndLogin(t, srv, "alice_pres", "alice-pres@example.com") + bobCookie := registerAndLogin(t, srv, "bob_pres", "bob-pres@example.com") + doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", `{"username":"bob_pres"}`, aliceCookie) + doJSON(t, srv, http.MethodPost, + "/api/v1/users/@me/relationships/"+formatSnowflake(mustUserID(t, srv, "alice-pres@example.com"))+"/accept", "", bobCookie) + + alice := dialGateway(t, httpServer, aliceCookie) + doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"status":"dnd"}`, bobCookie) + + frame := alice.expectEvent("PRESENCE_UPDATE") + var payload struct { + Status string `json:"status"` + VisibleStatus string `json:"visible_status"` + } + if err := json.Unmarshal(frame.D, &payload); err != nil { + t.Fatalf("decode PRESENCE_UPDATE: %v", err) + } + if payload.Status != "dnd" || payload.VisibleStatus != "dnd" { + t.Fatalf("presence payload = %+v", payload) + } +} + +// TestDirectChannelEventsOnlyForParticipants проверяет фильтрацию событий DM. +func TestDirectChannelEventsOnlyForParticipants(t *testing.T) { + srv, _ := newTestServer(t) + httpServer := httptest.NewServer(srv.Handler()) + t.Cleanup(httpServer.Close) + + aliceCookie := registerAndLogin(t, srv, "alice_dmev", "alice-dmev@example.com") + bobCookie := registerAndLogin(t, srv, "bob_dmev", "bob-dmev@example.com") + eveCookie := registerAndLogin(t, srv, "eve_dmev", "eve-dmev@example.com") + bobID := formatSnowflake(mustUserID(t, srv, "bob-dmev@example.com")) + + opened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+bobID+`"}`, aliceCookie) + channel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, opened) + + bob := dialGateway(t, httpServer, bobCookie) + eve := dialGateway(t, httpServer, eveCookie) + + doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channel.Channel.ID+"/messages", + `{"content":"личное сообщение"}`, aliceCookie) + bob.expectEvent("MESSAGE_CREATE") + eve.expectNoEvent("MESSAGE_CREATE", 700*time.Millisecond) +} + +// uploadAvatar загружает аватар через multipart и возвращает file_id. +func uploadAvatar(t *testing.T, server *httptest.Server, cookie *http.Cookie, filename string, content []byte) string { + t.Helper() + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`) + partHeader.Set("Content-Type", "image/png") + part, err := writer.CreatePart(partHeader) + if err != nil { + t.Fatalf("CreatePart: %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write avatar: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close writer: %v", err) + } + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + server.URL+"/api/v1/users/@me/avatar", bytes.NewReader(body.Bytes())) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(cookie) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("upload avatar: %v", err) + } + payload, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("upload avatar = %d, body = %s", resp.StatusCode, payload) + } + var decoded struct { + User struct { + AvatarFileID string `json:"avatar_file_id"` + } `json:"user"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("decode avatar response: %v", err) + } + return decoded.User.AvatarFileID +} diff --git a/internal/store/social.go b/internal/store/social.go new file mode 100644 index 0000000..49eee90 --- /dev/null +++ b/internal/store/social.go @@ -0,0 +1,340 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "strings" + "time" +) + +// RelationshipType — тип связи между пользователями (AGENT.md 7.8). +type RelationshipType string + +const ( + RelationshipFriend RelationshipType = "friend" + RelationshipOutgoing RelationshipType = "outgoing" + RelationshipIncoming RelationshipType = "incoming" + RelationshipBlocked RelationshipType = "blocked" +) + +// Relationship — связь «пользователь → другой пользователь». +type Relationship struct { + UserID uint64 + TargetID uint64 + Type RelationshipType + CreatedAt time.Time +} + +// SetRelationship записывает связь в одну сторону. +func (s *Store) SetRelationship(ctx context.Context, userID, targetID uint64, kind RelationshipType) error { + _, err := s.writer.ExecContext(ctx, ` + INSERT INTO relationships (user_id, target_id, type, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (user_id, target_id) DO UPDATE SET type = excluded.type, updated_at = excluded.updated_at`, + int64(userID), int64(targetID), string(kind), s.Now(), s.Now()) + return err +} + +// RemoveRelationship удаляет связь в одну сторону. +func (s *Store) RemoveRelationship(ctx context.Context, userID, targetID uint64) error { + _, err := s.writer.ExecContext(ctx, + `DELETE FROM relationships WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID)) + return err +} + +// GetRelationship возвращает связь или ErrNotFound. +func (s *Store) GetRelationship(ctx context.Context, userID, targetID uint64) (*Relationship, error) { + var ( + relation Relationship + createdAt string + ) + err := s.reader.QueryRowContext(ctx, ` + SELECT user_id, target_id, type, created_at FROM relationships + WHERE user_id = ? AND target_id = ?`, int64(userID), int64(targetID)). + Scan(&relation.UserID, &relation.TargetID, &relation.Type, &createdAt) + if err != nil { + return nil, mapError(err) + } + relation.CreatedAt = parseTimestamp(createdAt) + return &relation, nil +} + +// ListRelationships возвращает связи пользователя указанного типа вместе с +// профилями собеседников (для списка друзей). +func (s *Store) ListRelationships(ctx context.Context, userID uint64, kind RelationshipType) ([]RelationshipProfile, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT r.target_id, r.created_at, u.username, u.display_name, u.avatar_file_id, + u.status, u.custom_status, u.badges_json, u.last_seen_at, u.timezone, u.is_instance_admin + FROM relationships r + JOIN users u ON u.id = r.target_id + WHERE r.user_id = ? AND r.type = ? AND u.deleted_at IS NULL + ORDER BY u.username`, int64(userID), string(kind)) + if err != nil { + return nil, err + } + defer rows.Close() + + profiles := make([]RelationshipProfile, 0, 8) + for rows.Next() { + profile, err := scanRelationshipProfile(rows) + if err != nil { + return nil, err + } + profiles = append(profiles, *profile) + } + return profiles, rows.Err() +} + +// RelationshipProfile — связь вместе с публичным профилем собеседника. +type RelationshipProfile struct { + UserID uint64 + Username string + DisplayName string + AvatarFileID *uint64 + Status string + CustomStatus string + Badges []string + IsInstanceAdmin bool + LastSeenAt *time.Time + Timezone string + Since time.Time +} + +// CountFriends возвращает число друзей пользователя. +func (s *Store) CountFriends(ctx context.Context, userID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM relationships WHERE user_id = ? AND type = 'friend'`, + int64(userID)).Scan(&count) + return count, err +} + +// DMParticipants возвращает участников личной беседы. +func (s *Store) DMParticipants(ctx context.Context, channelID uint64) ([]uint64, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT user_id FROM dm_participants WHERE channel_id = ? ORDER BY joined_at`, int64(channelID)) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]uint64, 0, 2) + for rows.Next() { + var userID uint64 + if err := rows.Scan(&userID); err != nil { + return nil, err + } + users = append(users, userID) + } + return users, rows.Err() +} + +// IsDMParticipant проверяет участие пользователя в личной беседе. +func (s *Store) IsDMParticipant(ctx context.Context, channelID, userID uint64) (bool, error) { + var exists int + err := s.reader.QueryRowContext(ctx, + `SELECT 1 FROM dm_participants WHERE channel_id = ? AND user_id = ?`, + int64(channelID), int64(userID)).Scan(&exists) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// CreateDMChannel создаёт личную беседу двух пользователей. +func (s *Store) CreateDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) { + channelID := s.NextID() + if err := s.InTx(ctx, func(tx *sql.Tx) error { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO channels (id, guild_id, type, name, position, created_at) + VALUES (?, NULL, 'dm', '', 0, ?)`, int64(channelID), s.Now()); err != nil { + return err + } + for _, userID := range []uint64{firstID, secondID} { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO dm_participants (channel_id, user_id, joined_at) VALUES (?, ?, ?)`, + int64(channelID), int64(userID), s.Now()); err != nil { + return err + } + } + return nil + }); err != nil { + return nil, mapError(err) + } + return s.GetChannel(ctx, channelID) +} + +// FindDMChannel ищет существующую личную беседу двух пользователей. +func (s *Store) FindDMChannel(ctx context.Context, firstID, secondID uint64) (*Channel, error) { + var channelID uint64 + err := s.reader.QueryRowContext(ctx, ` + SELECT p1.channel_id + FROM dm_participants p1 + JOIN dm_participants p2 ON p2.channel_id = p1.channel_id + JOIN channels c ON c.id = p1.channel_id + WHERE p1.user_id = ? AND p2.user_id = ? AND c.type = 'dm' + ORDER BY p1.channel_id LIMIT 1`, int64(firstID), int64(secondID)).Scan(&channelID) + if err != nil { + return nil, mapError(err) + } + return s.GetChannel(ctx, channelID) +} + +// DMChannelSummary — личная беседа со собеседником и последним сообщением. +type DMChannelSummary struct { + Channel Channel + RecipientID uint64 + RecipientName string + RecipientLogin string + AvatarFileID *uint64 + Status string + LastSeenAt *time.Time + Timezone string + LastMessageID uint64 + LastMessageAt *time.Time + LastAuthorID *uint64 +} + +// ListDMChannels возвращает личные беседы пользователя. +func (s *Store) ListDMChannels(ctx context.Context, userID uint64) ([]DMChannelSummary, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT c.id, c.created_at, u.id, u.display_name, u.username, u.avatar_file_id, u.status, + u.last_seen_at, u.timezone, + (SELECT m.id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1), + (SELECT m.created_at FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1), + (SELECT m.author_id FROM messages m WHERE m.channel_id = c.id ORDER BY m.id DESC LIMIT 1) + FROM dm_participants p + JOIN channels c ON c.id = p.channel_id AND c.type = 'dm' + JOIN dm_participants other ON other.channel_id = c.id AND other.user_id <> p.user_id + JOIN users u ON u.id = other.user_id AND u.deleted_at IS NULL + WHERE p.user_id = ? + ORDER BY c.id DESC`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + channels := make([]DMChannelSummary, 0, 8) + for rows.Next() { + var ( + summary DMChannelSummary + createdAt string + avatarID sql.NullInt64 + lastSeenAt sql.NullString + lastMessageID sql.NullInt64 + lastMessageAt sql.NullString + lastAuthorID sql.NullInt64 + ) + if err := rows.Scan(&summary.Channel.ID, &createdAt, &summary.RecipientID, &summary.RecipientName, + &summary.RecipientLogin, &avatarID, &summary.Status, &lastSeenAt, &summary.Timezone, + &lastMessageID, &lastMessageAt, &lastAuthorID); err != nil { + return nil, err + } + if lastSeenAt.Valid { + value := parseTimestamp(lastSeenAt.String) + summary.LastSeenAt = &value + } + summary.Channel.Type = ChannelDM + summary.Channel.CreatedAt = parseTimestamp(createdAt) + summary.AvatarFileID = optionalID(avatarID) + if lastMessageID.Valid { + summary.LastMessageID = uint64(lastMessageID.Int64) + } + if lastMessageAt.Valid { + value := parseTimestamp(lastMessageAt.String) + summary.LastMessageAt = &value + } + summary.LastAuthorID = optionalID(lastAuthorID) + channels = append(channels, summary) + } + return channels, rows.Err() +} + +// TouchLastSeen обновляет время последней активности пользователя. +func (s *Store) TouchLastSeen(ctx context.Context, userID uint64) error { + _, err := s.writer.ExecContext(ctx, + `UPDATE users SET last_seen_at = ? WHERE id = ? AND deleted_at IS NULL`, s.Now(), int64(userID)) + return err +} + +// SetTimezone сохраняет выбранный часовой пояс пользователя. +func (s *Store) SetTimezone(ctx context.Context, userID uint64, timezone string) error { + _, err := s.writer.ExecContext(ctx, + `UPDATE users SET timezone = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + timezone, s.Now(), int64(userID)) + return err +} + +// SearchUsersByUsername ищет пользователей по началу username (для поиска друзей). +func (s *Store) SearchUsersByUsername(ctx context.Context, query string, excludeID uint64, limit int) ([]User, error) { + if limit <= 0 || limit > 50 { + limit = 20 + } + // ESCAPE обязателен: логины содержат «_», который в LIKE — подстановочный + // символ, поэтому экранируем его и сообщаем об этом SQLite. + rows, err := s.reader.QueryContext(ctx, ` + SELECT `+userColumns+` FROM users + WHERE deleted_at IS NULL AND id <> ? AND username_lower LIKE ? ESCAPE '\' + ORDER BY username_lower LIMIT ?`, + int64(excludeID), escapeLike(strings.ToLower(query))+"%", limit) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]User, 0, limit) + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, *user) + } + return users, rows.Err() +} + +// escapeLike экранирует служебные символы LIKE, чтобы поиск не превращался в шаблон. +func escapeLike(value string) string { + replacer := []struct{ from, to string }{ + {"\\", "\\\\"}, + {"%", "\\%"}, + {"_", "\\_"}, + } + result := value + for _, rule := range replacer { + result = strings.ReplaceAll(result, rule.from, rule.to) + } + return result +} + +func scanRelationshipProfile(scanner interface{ Scan(...any) error }) (*RelationshipProfile, error) { + var ( + profile RelationshipProfile + avatarID sql.NullInt64 + badges string + lastSeen sql.NullString + isAdmin int + createdAt string + ) + err := scanner.Scan(&profile.UserID, &createdAt, &profile.Username, &profile.DisplayName, &avatarID, + &profile.Status, &profile.CustomStatus, &badges, &lastSeen, &profile.Timezone, &isAdmin) + if err != nil { + return nil, mapError(err) + } + profile.AvatarFileID = optionalID(avatarID) + profile.IsInstanceAdmin = isAdmin == 1 + if err := json.Unmarshal([]byte(badges), &profile.Badges); err != nil { + profile.Badges = nil + } + if lastSeen.Valid { + value := parseTimestamp(lastSeen.String) + profile.LastSeenAt = &value + } + profile.Since = parseTimestamp(createdAt) + return &profile, nil +} diff --git a/internal/store/users.go b/internal/store/users.go index 985673e..444fa7f 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -31,6 +31,10 @@ type User struct { DeletedAt *time.Time // OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2). OnboardingCompletedAt *time.Time + // LastSeenAt — время последней активности (для статусов друзей). + LastSeenAt *time.Time + // Timezone — часовой пояс пользователя (IANA, по умолчанию Europe/Moscow). + Timezone string } // CreateUserParams — данные новой учётной записи: шифрование и blind index @@ -48,7 +52,7 @@ type CreateUserParams struct { const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id, banner_file_id, bio, status, custom_status, custom_status_emoji, flags, is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at, - onboarding_completed_at` + onboarding_completed_at, last_seen_at, timezone` func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) { if params.ID == 0 { @@ -235,6 +239,14 @@ func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error { return nil } +// ClearAvatar убирает ссылку на файл аватара. +func (s *Store) ClearAvatar(ctx context.Context, id uint64) error { + _, err := s.writer.ExecContext(ctx, + `UPDATE users SET avatar_file_id = NULL, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + s.Now(), int64(id)) + return err +} + // ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5). func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) { if limit <= 0 || limit > 200 { @@ -273,13 +285,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { updatedAt string deletedAt sql.NullString onboardingAt sql.NullString + lastSeenAt sql.NullString emailEncrypted string ) err := scanner.Scan( &user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash, &avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji, &user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt, - &onboardingAt, + &onboardingAt, &lastSeenAt, &user.Timezone, ) if err != nil { return nil, mapError(err) @@ -306,6 +319,13 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { value := parseTimestamp(onboardingAt.String) user.OnboardingCompletedAt = &value } + if lastSeenAt.Valid { + value := parseTimestamp(lastSeenAt.String) + user.LastSeenAt = &value + } + if user.Timezone == "" { + user.Timezone = "Europe/Moscow" + } return &user, nil } diff --git a/web/src/components/chat/MessageContent.tsx b/web/src/components/chat/MessageContent.tsx index 750294e..f739e79 100644 --- a/web/src/components/chat/MessageContent.tsx +++ b/web/src/components/chat/MessageContent.tsx @@ -1,12 +1,19 @@ -import { Fragment, type ReactNode } from 'react'; +import { Fragment, useMemo, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useSessionStore } from '@/stores/session'; /** * Лёгкий безопасный рендер разметки сообщения. * * `dangerouslySetInnerHTML` не используется: текст разбирается в React-узлы, * поэтому HTML из сообщения остаётся текстом. Поддерживается: `**жирный**`, - * `*курсив*`, `` `код` ``, ```блок кода```, `~~зачёркнутый~~`, `> цитата`, - * ссылки `http(s)://…` и упоминания `<@id>`. + * `__жирный__`, `*курсив*`, `_курсив_`, `***жирный курсив***`, `` `код` ``, + * ```блок кода```, `~~зачёркнутый~~`, `==выделение==`, `||спойлер||`, + * `> цитата`, ссылки `http(s)://…`, упоминания `<@id>` и `#канал`. + * + * Незакрытые маркеры остаются обычным текстом: шаблон требует закрывающую + * пару, поэтому `==фыв` и `**фыв` рендерятся как есть. */ export interface MessageContentProps { @@ -16,8 +23,13 @@ export interface MessageContentProps { className?: string; } +/** + * Порядок альтернатив важен: сначала более длинные маркеры (`***` раньше `**` + * и `*`), ссылки — раньше `#канала`, чтобы `https://x/#якорь` целиком остался + * ссылкой. Подчёркивания внутри слова (`foo_bar_baz`) курсивом не выделяются. + */ const INLINE_PATTERN = - /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\*[^*\n]+\*)|(~~[^~\n]+~~)|(<@\d+>)|(https?:\/\/[^\s<>()]+)/gu; + /(`[^`\n]+`)|(\*\*\*[^*\n]+\*\*\*)|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(~~[^~\n]+~~)|(\|\|[^|\n]+\|\|)|(\*[^*\n]+\*)|((?)|(https?:\/\/[^\s<>()]+)|(#[\p{L}\p{N}_-]+)/gu; /** Ссылка: обрезаем хвостовую пунктуацию, которая не частью адреса. */ function splitLink(raw: string): { href: string; trailing: string } { @@ -32,9 +44,33 @@ function splitLink(raw: string): { href: string; trailing: string } { type MentionResolver = MessageContentProps['resolveMention']; +/** + * Спойлер: текст скрыт до клика. Это кнопка, поэтому состояние живёт внутри + * узла, а сам текст остаётся в DOM (скринридеру он тоже доступен). + */ +function Spoiler({ children }: { children: ReactNode }) { + const { t } = useTranslation(); + const [revealed, setRevealed] = useState(false); + + return ( + + ); +} + function renderInline( text: string, resolveMention: MentionResolver, + channels: ReadonlySet, keyPrefix: string, ): ReactNode[] { const nodes: ReactNode[] = []; @@ -59,20 +95,42 @@ function renderInline( {token.slice(1, -1)} , ); + } else if (token.startsWith('***')) { + nodes.push( + + {token.slice(3, -3)} + , + ); } else if (token.startsWith('**')) { nodes.push( {token.slice(2, -2)} , ); + } else if (token.startsWith('__')) { + nodes.push( + + {token.slice(2, -2)} + , + ); } else if (token.startsWith('~~')) { nodes.push( {token.slice(2, -2)} , ); + } else if (token.startsWith('||')) { + nodes.push({token.slice(2, -2)}); } else if (token.startsWith('*')) { nodes.push({token.slice(1, -1)}); + } else if (token.startsWith('_')) { + nodes.push({token.slice(1, -1)}); + } else if (token.startsWith('==')) { + nodes.push( + + {token.slice(2, -2)} + , + ); } else if (token.startsWith('<@')) { const userId = token.slice(2, -1); const mention = resolveMention?.(userId) ?? null; @@ -86,7 +144,7 @@ function renderInline( @{mention?.name ?? userId} , ); - } else { + } else if (token.startsWith('http')) { const { href, trailing } = splitLink(token); nodes.push( @@ -101,6 +159,19 @@ function renderInline( {trailing} , ); + } else { + // `#канал`: подсвечиваем только известный канал, иначе оставляем текстом. + const name = token.slice(1); + const known = channels.has(name.toLowerCase()); + nodes.push( + known ? ( + + {token} + + ) : ( + token + ), + ); } lastIndex = start + token.length; } @@ -162,6 +233,17 @@ function toBlocks(content: string): Block[] { export function MessageContent({ content, resolveMention, className = '' }: MessageContentProps) { const blocks = toBlocks(content); + // Имена каналов снапшота: `#канал` подсвечиваем только для них. + const guilds = useSessionStore((state) => state.guilds); + const channels = useMemo(() => { + const names = new Set(); + for (const guild of guilds) { + for (const channel of guild.channels) { + names.add(channel.name.toLowerCase()); + } + } + return names; + }, [guilds]); return (
@@ -180,13 +262,13 @@ export function MessageContent({ content, resolveMention, className = '' }: Mess if (block.kind === 'quote') { return (
- {renderInline(block.lines.join('\n'), resolveMention, key)} + {renderInline(block.lines.join('\n'), resolveMention, channels, key)}
); } return (

- {renderInline(block.lines.join('\n'), resolveMention, key)} + {renderInline(block.lines.join('\n'), resolveMention, channels, key)}

); })} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 8421e8d..2235620 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -15,7 +15,11 @@ interface LocationState { from?: string; } -/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */ +/** + * Форма входа. Поле кода 2FA показывается всегда: сервер принимает и код из + * приложения (6 цифр), и резервный код вида `a8eh-pshp-t8st`, а при попытке + * входа без кода отвечает `auth.2fa_required` — тогда подсказываем про код. + */ export default function LoginPage() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -30,8 +34,11 @@ export default function LoginPage() { const from = (location.state as LocationState | null)?.from ?? '/app'; const submit = useMutation({ - mutationFn: () => - login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }), + mutationFn: () => { + // Пустое поле — вход без кода: сервер сам скажет, нужен ли он. + const code = totpCode.trim(); + return login(code === '' ? { email, password } : { email, password, totp_code: code }); + }, onSuccess: async () => { // Профиль перечитываем заново: cookie уже выставлена сервером. await getQueryClient().invalidateQueries(); @@ -39,6 +46,7 @@ export default function LoginPage() { }, onError: (error: unknown) => { if (errorCode(error) === 'auth.2fa_required') { + // Логин, пароль и уже введённый код остаются в форме. setNeedsTotp(true); } }, @@ -46,6 +54,7 @@ export default function LoginPage() { const onSubmit = (event: FormEvent): void => { event.preventDefault(); + setNeedsTotp(false); submit.mutate(); }; @@ -79,22 +88,24 @@ export default function LoginPage() { onChange={(event) => setPassword(event.target.value)} required /> + setTotpCode(event.target.value)} + /> {needsTotp ? ( - setTotpCode(event.target.value)} - autoFocus - /> +

+ {t('auth.login.totpRequired')} +

) : null} - {submit.isError ? : null} + {/* При `auth.2fa_required` подсказка уже показана у поля — не дублируем. */} + {submit.isError && !needsTotp ? : null}