diff --git a/internal/server/api_messages.go b/internal/server/api_messages.go index 22ed28c..4068992 100644 --- a/internal/server/api_messages.go +++ b/internal/server/api_messages.go @@ -107,6 +107,16 @@ func (s *Server) registerMessageRoutes(api huma.API) { if err != nil { return nil, err } + // Личная беседа: блокировка запрещает переписку в обе стороны (AGENT.md 7.2). + if channel.GuildID == nil { + blocked, err := s.store.DMBlocked(ctx, channelID, user.ID) + if err != nil { + return nil, humaError(err) + } + if blocked { + return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") + } + } // Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя, // администратор инстанса лимит обходит (AGENT.md 8.6, 7.19). if !user.IsInstanceAdmin { diff --git a/internal/server/api_social.go b/internal/server/api_social.go index 269269e..c7da81c 100644 --- a/internal/server/api_social.go +++ b/internal/server/api_social.go @@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) { if err != nil { return nil, err } + // Блокировка сильнее заявки: переписка и дружба с ней невозможны. + if blocked, err := s.store.IsBlocked(ctx, user.ID, target.ID); err != nil { + return nil, humaError(err) + } else if blocked { + return nil, humaErrorStatus(http.StatusForbidden, "relationship.blocked", "user is blocked") + } // Если встречная заявка уже есть — сразу становимся друзьями. reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID) switch { @@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) { return newOKOutput(), nil }) + huma.Register(api, huma.Operation{ + OperationID: "blockUser", + Method: http.MethodPut, + Path: "/users/@me/blocks/{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 targetID == user.ID { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot block yourself") + } + if _, err := s.store.GetUser(ctx, targetID); err != nil { + return nil, humaError(err) + } + // Блокировка снимает дружбу и заявки в обе стороны (AGENT.md 7.2). + if err := s.store.BlockUser(ctx, user.ID, targetID); err != nil { + return nil, humaError(err) + } + s.dispatchRelationshipUpdate(user.ID, targetID) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "unblockUser", + Method: http.MethodDelete, + Path: "/users/@me/blocks/{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.UnblockUser(ctx, user.ID, targetID); err != nil { + return nil, humaError(err) + } + s.dispatchRelationshipUpdate(user.ID, targetID) + return newOKOutput(), nil + }) + huma.Register(api, huma.Operation{ OperationID: "openDirectChannel", Method: http.MethodPost, @@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) { if err != nil { return nil, humaError(err) } + // С заблокированным беседа не открывается (AGENT.md 7.2). + if blocked, err := s.store.IsBlocked(ctx, user.ID, recipientID); err != nil { + return nil, humaError(err) + } else if blocked { + return nil, humaErrorStatus(http.StatusForbidden, "dm.blocked", "personal messages are blocked") + } channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID) if errors.Is(err, store.ErrNotFound) { channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID) diff --git a/internal/server/cosmetics_test.go b/internal/server/cosmetics_test.go index 9ef4f3a..4b2f03d 100644 --- a/internal/server/cosmetics_test.go +++ b/internal/server/cosmetics_test.go @@ -521,3 +521,84 @@ func TestMembersCarrySystemBadges(t *testing.T) { t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String()) } } + +func TestBlockUserStopsDMsAndRequests(t *testing.T) { + srv, owner, member, _, memberID := cosmeticsFixture(t) + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", owner) + ownerID := decodeResponse[struct { + User struct { + ID string `json:"id"` + } `json:"user"` + }](t, me).User.ID + + // Заводим дружбу, затем блокируем: связь снимается, остаётся блокировка. + request := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", + `{"user_id":"`+memberID+`"}`, owner) + if request.Code != http.StatusOK { + t.Fatalf("заявка = %d (%s)", request.Code, request.Body.String()) + } + if accept := doJSON(t, srv, http.MethodPost, + "/api/v1/users/@me/relationships/"+ownerID+"/accept", "", member); accept.Code != http.StatusOK { + t.Fatalf("принятие = %d", accept.Code) + } + + blocked := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner) + if blocked.Code != http.StatusOK { + t.Fatalf("блокировка = %d (%s)", blocked.Code, blocked.Body.String()) + } + relations := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/relationships", "", owner) + list := decodeResponse[struct { + Friends []struct { + UserID string `json:"user_id"` + } `json:"friends"` + Blocked []struct { + UserID string `json:"user_id"` + } `json:"blocked"` + }](t, relations) + if len(list.Friends) != 0 || len(list.Blocked) != 1 || list.Blocked[0].UserID != memberID { + t.Fatalf("после блокировки: друзей %d, заблокированных %d", len(list.Friends), len(list.Blocked)) + } + + // Заявка в друзья от заблокированного отклоняется. + again := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/relationships", + `{"user_id":"`+ownerID+`"}`, member) + if again.Code != http.StatusForbidden { + t.Fatalf("заявка от заблокированного = %d, ожидался 403 (%s)", again.Code, again.Body.String()) + } + + // Личная беседа не открывается и сообщения в неё не уходят. + open := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", + `{"recipient_id":"`+memberID+`"}`, owner) + if open.Code != http.StatusForbidden { + t.Fatalf("беседа с заблокированным = %d, ожидался 403 (%s)", open.Code, open.Body.String()) + } + + // Снятие блокировки возвращает возможность писать. + if unblock := doJSON(t, srv, http.MethodDelete, "/api/v1/users/@me/blocks/"+memberID, "", owner); unblock.Code != http.StatusOK { + t.Fatalf("снятие блокировки = %d", unblock.Code) + } + reopened := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", + `{"recipient_id":"`+memberID+`"}`, owner) + if reopened.Code != http.StatusOK { + t.Fatalf("беседа после снятия блокировки = %d (%s)", reopened.Code, reopened.Body.String()) + } + channelID := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, reopened).Channel.ID + + // Блокируем снова уже при существующей беседе: сообщение не отправляется. + if rec := doJSON(t, srv, http.MethodPut, "/api/v1/users/@me/blocks/"+memberID, "", owner); rec.Code != http.StatusOK { + t.Fatalf("повторная блокировка = %d", rec.Code) + } + post := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", + `{"content":"привет"}`, owner) + if post.Code != http.StatusForbidden { + t.Fatalf("сообщение заблокированному = %d, ожидался 403 (%s)", post.Code, post.Body.String()) + } + if code := errorCodeOf(t, post); code != "dm.blocked" { + t.Fatalf("код ошибки = %q", code) + } + _ = ownerID +} diff --git a/internal/store/blocks.go b/internal/store/blocks.go new file mode 100644 index 0000000..9cf421c --- /dev/null +++ b/internal/store/blocks.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" +) + +// Блокировка пользователя (AGENT.md 7.2, 8.2): запрещает личные сообщения и +// заявки в друзья. Хранится той же таблицей связей, что друзья: тип `blocked` +// сильнее остальных, поэтому при блокировке встречные связи снимаются. + +// BlockUser блокирует пользователя: дружба и заявки в обе стороны снимаются, +// у блокирующего остаётся связь `blocked`. +func (s *Store) BlockUser(ctx context.Context, userID, targetID uint64) error { + if userID == targetID { + return ErrConflict + } + tx, err := s.writer.BeginTx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + // Снимаем все встречные связи: и дружбу, и заявки. + if _, err := tx.ExecContext(ctx, + `DELETE FROM relationships WHERE (user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?)`, + int64(userID), int64(targetID), int64(targetID), int64(userID)); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO relationships (user_id, target_id, type, created_at, updated_at) + VALUES (?, ?, 'blocked', ?, ?)`, + int64(userID), int64(targetID), s.Now(), s.Now()); err != nil { + return err + } + return tx.Commit() +} + +// UnblockUser снимает блокировку; дружбу после неё нужно заводить заново. +func (s *Store) UnblockUser(ctx context.Context, userID, targetID uint64) error { + relation, err := s.GetRelationship(ctx, userID, targetID) + if err != nil { + return err + } + if relation.Type != RelationshipBlocked { + return ErrNotFound + } + return s.RemoveRelationship(ctx, userID, targetID) +} + +// IsBlocked сообщает, блокирует ли пользователь другого (в любую сторону). +func (s *Store) IsBlocked(ctx context.Context, userID, targetID uint64) (bool, error) { + var count int + err := s.reader.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM relationships + WHERE type = 'blocked' + AND ((user_id = ? AND target_id = ?) OR (user_id = ? AND target_id = ?))`, + int64(userID), int64(targetID), int64(targetID), int64(userID)).Scan(&count) + if err != nil { + return false, err + } + return count > 0, nil +} + +// ListBlocked возвращает заблокированных пользователей (для списка в клиенте). +func (s *Store) ListBlocked(ctx context.Context, userID uint64) ([]RelationshipProfile, error) { + return s.ListRelationships(ctx, userID, RelationshipBlocked) +} + +// BlockedIDs возвращает множество пользователей, которые заблокированы +// пользователем: нужно для фильтрации поиска и списков. +func (s *Store) BlockedIDs(ctx context.Context, userID uint64) (map[uint64]bool, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT target_id FROM relationships WHERE user_id = ? AND type = 'blocked'`, int64(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + ids := map[uint64]bool{} + for rows.Next() { + var id uint64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids[id] = true + } + return ids, rows.Err() +} + +// DMBlocked проверяет, есть ли среди участников личной беседы тот, кто +// блокирует пользователя или кого блокирует он сам: переписка запрещена +// (AGENT.md 7.2). +func (s *Store) DMBlocked(ctx context.Context, channelID, userID uint64) (bool, error) { + rows, err := s.reader.QueryContext(ctx, + `SELECT user_id FROM dm_participants WHERE channel_id = ?`, int64(channelID)) + if err != nil { + return false, err + } + defer rows.Close() + + participants := make([]uint64, 0, 2) + for rows.Next() { + var id uint64 + if err := rows.Scan(&id); err != nil { + return false, err + } + participants = append(participants, id) + } + if err := rows.Err(); err != nil { + return false, err + } + for _, participant := range participants { + if participant == userID { + continue + } + blocked, err := s.IsBlocked(ctx, userID, participant) + if err != nil { + return false, err + } + if blocked { + return true, nil + } + } + return false, nil +} diff --git a/web/src/api/friends.ts b/web/src/api/friends.ts index 5d6e7f0..308fdac 100644 --- a/web/src/api/friends.ts +++ b/web/src/api/friends.ts @@ -188,6 +188,20 @@ export async function acceptFriendRequest(userId: string): Promise { }); } +/** `PUT /users/@me/blocks/{id}` — заблокировать (AGENT.md 7.2). */ +export async function blockUser(userId: string): Promise { + await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, { + method: 'PUT', + }); +} + +/** `DELETE /users/@me/blocks/{id}` — снять блокировку. */ +export async function unblockUser(userId: string): Promise { + await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, { + method: 'DELETE', + }); +} + /** * `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или * отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка — diff --git a/web/src/components/friends/FriendRow.tsx b/web/src/components/friends/FriendRow.tsx index 2b00941..c6d815a 100644 --- a/web/src/components/friends/FriendRow.tsx +++ b/web/src/components/friends/FriendRow.tsx @@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { removeRelationship, type RelationshipUser } from '@/api/friends'; +import { blockUser, removeRelationship, type RelationshipUser } from '@/api/friends'; import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; import { SystemBadges } from '@/components/profile/SystemBadges'; import { ErrorNotice } from '@/components/ui/ErrorNotice'; @@ -33,6 +33,11 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) { const nameRef = useRef(null); const menuRef = useRef(null); + const block = useMutation({ + mutationFn: () => blockUser(friend.user_id), + onSuccess: () => reload(), + }); + const remove = useMutation({ mutationFn: () => removeRelationship(friend.user_id), onSuccess: () => reload(), @@ -180,11 +185,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) { { setMenuOpen(false); - // Отдельной ручки блокировки нет: блокировка — тот же DELETE. - remove.mutate(); + // Блокировка запрещает личные сообщения и снимает дружбу (AGENT.md 7.2). + block.mutate(); }} > {t('friends.list.block')} diff --git a/web/src/components/friends/FriendsView.tsx b/web/src/components/friends/FriendsView.tsx index 9ab71d4..b2193a8 100644 --- a/web/src/components/friends/FriendsView.tsx +++ b/web/src/components/friends/FriendsView.tsx @@ -2,8 +2,14 @@ import { useEffect, useId, useState } from 'react'; import { useMutation } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { acceptFriendRequest, removeRelationship, type RelationshipUser } from '@/api/friends'; +import { + acceptFriendRequest, + removeRelationship, + unblockUser, + type RelationshipUser, +} from '@/api/friends'; import { FriendRow } from '@/components/friends/FriendRow'; +import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics'; import { InviteJoin } from '@/components/friends/InviteJoin'; import { UserSearch } from '@/components/friends/UserSearch'; import { Avatar } from '@/components/ui/Avatar'; @@ -29,6 +35,7 @@ export function FriendsView() { const friends = useFriendsStore((state) => state.friends); const incoming = useFriendsStore((state) => state.incoming); const outgoing = useFriendsStore((state) => state.outgoing); + const blocked = useFriendsStore((state) => state.blocked); const status = useFriendsStore((state) => state.status); const error = useFriendsStore((state) => state.error); const load = useFriendsStore((state) => state.load); @@ -44,6 +51,12 @@ export function FriendsView() { void load(); }, [load]); + // Снятие блокировки: список перечитывается, человек снова может писать. + const unblock = useMutation({ + mutationFn: (userId: string) => unblockUser(userId), + onSuccess: () => reload(), + }); + const respond = useMutation({ mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) => kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId), @@ -222,7 +235,49 @@ export function FriendsView() { )} + {blocked.length === 0 ? null : ( +
+

+ {t('friends.blocked.section', { count: blocked.length })} +

+
    + {blocked.map((user) => ( +
  • + + + + @{user.username} + + +
  • + ))} +
+
+ )} + {respond.isError ? : null} + {unblock.isError ? : null} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index fbc12d9..40be205 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -418,6 +418,11 @@ "title": "Conversation unavailable", "body": "This direct conversation was not found: it may be deleted or closed to you." } + }, + "blocked": { + "section": "Blocked ({{count}})", + "unblock": "Unblock", + "alreadyBlocked": "Blocked" } }, "invites": { diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 003d32e..5174818 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -418,6 +418,11 @@ "title": "Беседа недоступна", "body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт." } + }, + "blocked": { + "section": "Заблокированные ({{count}})", + "unblock": "Разблокировать", + "alreadyBlocked": "Заблокирован" } }, "invites": { diff --git a/web/src/pages/settings/MemberModerationMenu.tsx b/web/src/pages/settings/MemberModerationMenu.tsx index 9dc20ea..ca96a99 100644 --- a/web/src/pages/settings/MemberModerationMenu.tsx +++ b/web/src/pages/settings/MemberModerationMenu.tsx @@ -9,7 +9,9 @@ import { kickGuildMember, timeoutGuildMember, } from '@/api/moderation'; +import { blockUser } from '@/api/friends'; import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu'; +import { useFriendsStore } from '@/stores/friends'; import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; /** Пресеты тайм-аута в минутах: 0 — снять ограничение. */ @@ -64,6 +66,17 @@ export function MemberModerationMenu({ close(); }, }); + const reloadRelationships = useFriendsStore((state) => state.reload); + const blockedUsers = useFriendsStore((state) => state.blocked); + const blocked = blockedUsers.some((entry) => entry.user_id === userId); + + const block = useMutation({ + mutationFn: () => blockUser(userId), + onSuccess: () => { + void reloadRelationships(); + }, + }); + const kick = useMutation({ mutationFn: () => kickGuildMember(guildId, userId), onSuccess: () => { @@ -162,6 +175,16 @@ export function MemberModerationMenu({
) : null} + {/* Личная блокировка не зависит от прав на сервере (AGENT.md 7.2). */} + block.mutate()} + > + {t(blocked ? 'friends.blocked.alreadyBlocked' : 'friends.list.block')} + + {error === null || error === undefined ? null : (

{describeError(error)}

)} diff --git a/web/tests/friends.test.tsx b/web/tests/friends.test.tsx index a13d2fb..4e7a9d6 100644 --- a/web/tests/friends.test.tsx +++ b/web/tests/friends.test.tsx @@ -15,6 +15,7 @@ import { messagesRoutes, recordedRequests, renderApp, + waitForPage, type FetchRoute, } from './helpers'; @@ -891,3 +892,63 @@ describe('события шлюза о друзьях', () => { }); }); }); + +describe('блокировка пользователей', () => { + it('показывает заблокированных и снимает блокировку', async () => { + const blocked = relationship({ + user_id: 'user-7', + username: 'spammer', + display_name: 'Spammer', + relationship: 'blocked', + }); + const fetchMock = installFetch( + baseRoutes({ friends: [], incoming: [], outgoing: [], blocked: [blocked] }, [ + { + match: '/api/v1/users/@me/blocks/user-7', + method: 'DELETE', + response: () => json({ ok: true }), + }, + ]), + ); + renderApp('/app/friends'); + await waitForPage('Друзья'); + + const section = await screen.findByTestId('friends-blocked'); + expect(within(section).getByText('Spammer')).toBeVisible(); + + await userEvent.click(screen.getByTestId('blocked-unblock-user-7')); + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.method === 'DELETE' && request.url.includes('/blocks/user-7'), + ), + ).toBe(true); + }); + }); + + it('блокирует друга из меню строки', async () => { + const fetchMock = installFetch( + baseRoutes({ friends: [bob], incoming: [], outgoing: [], blocked: [] }, [ + { + match: '/api/v1/users/@me/blocks/user-2', + method: 'PUT', + response: () => json({ ok: true }), + }, + ]), + ); + renderApp('/app/friends'); + await waitForPage('Друзья'); + await userEvent.click(await screen.findByTestId('friends-list-toggle')); + + await userEvent.click(await screen.findByLabelText('Действия: Боб')); + await userEvent.click(await screen.findByTestId('friend-block-user-2')); + + await waitFor(() => { + expect( + recordedRequests(fetchMock).some( + (request) => request.method === 'PUT' && request.url.includes('/blocks/user-2'), + ), + ).toBe(true); + }); + }); +});