Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 566e1cf981 | |||
| ed98c78c9e |
@@ -0,0 +1,7 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- Переопределение splash для конкретного приглашения (AGENT.md 7.9): страница
|
||||||
|
-- приглашения показывает свою картинку вместо splash сервера.
|
||||||
|
ALTER TABLE invites ADD COLUMN background_file_id INTEGER REFERENCES files (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE invites DROP COLUMN background_file_id;
|
||||||
@@ -301,7 +301,8 @@ func (s *Server) requireFileAccess(ctx context.Context, rawFileID string, user *
|
|||||||
// Аватары и баннеры видны всем авторизованным: они показываются в списках
|
// Аватары и баннеры видны всем авторизованным: они показываются в списках
|
||||||
// участников и друзей (AGENT.md 7.2).
|
// участников и друзей (AGENT.md 7.2).
|
||||||
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
|
if file.Purpose == "avatar" || file.Purpose == "banner" || file.Purpose == "emoji" ||
|
||||||
file.Purpose == "sound" || file.Purpose == "webhook_avatar" {
|
file.Purpose == "sound" || file.Purpose == "webhook_avatar" ||
|
||||||
|
file.Purpose == "invite_background" {
|
||||||
return file, nil
|
return file, nil
|
||||||
}
|
}
|
||||||
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
// Фон комнаты виден тем, кто видит саму комнату (проверка ниже по каналу).
|
||||||
|
|||||||
+151
-12
@@ -1,14 +1,17 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/danielgtaylor/huma/v2"
|
"github.com/danielgtaylor/huma/v2"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"glchat/internal/permissions"
|
"glchat/internal/permissions"
|
||||||
"glchat/internal/store"
|
"glchat/internal/store"
|
||||||
@@ -28,6 +31,9 @@ type invitePayload struct {
|
|||||||
ExpiresAt string `json:"expires_at,omitempty"`
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
// BackgroundFileID — переопределение splash для страницы приглашения
|
||||||
|
// (AGENT.md 7.9).
|
||||||
|
BackgroundFileID string `json:"background_file_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type inviteListOutput struct {
|
type inviteListOutput struct {
|
||||||
@@ -49,6 +55,13 @@ type inviteOutput struct {
|
|||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
MemberCount int `json:"member_count"`
|
MemberCount int `json:"member_count"`
|
||||||
IsMember bool `json:"is_member"`
|
IsMember bool `json:"is_member"`
|
||||||
|
// Inviter — кто позвал: страница приглашения показывает имя.
|
||||||
|
Inviter struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
AvatarFileID string `json:"avatar_file_id,omitempty"`
|
||||||
|
} `json:"inviter"`
|
||||||
} `json:"guild"`
|
} `json:"guild"`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,24 +225,24 @@ func (s *Server) registerInviteRoutes(api huma.API) {
|
|||||||
Path: "/invites/{code}",
|
Path: "/invites/{code}",
|
||||||
Summary: "Предпросмотр приглашения",
|
Summary: "Предпросмотр приглашения",
|
||||||
Tags: []string{"Invites"},
|
Tags: []string{"Invites"},
|
||||||
Security: security,
|
// Карточку приглашения видно и без сессии: по ссылке приходят те, у кого
|
||||||
|
// аккаунта ещё нет (AGENT.md 7.9). Присоединение требует входа.
|
||||||
}, func(ctx context.Context, input *struct {
|
}, func(ctx context.Context, input *struct {
|
||||||
Code string `path:"code"`
|
Code string `path:"code"`
|
||||||
},
|
},
|
||||||
) (*inviteOutput, error) {
|
) (*inviteOutput, error) {
|
||||||
user, _, err := requireUser(ctx)
|
user, _, _ := sessionFromContext(ctx)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
invite, err := s.store.GetInvite(ctx, input.Code)
|
invite, err := s.store.GetInvite(ctx, input.Code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
return nil, humaError(err)
|
||||||
}
|
}
|
||||||
// Бан сервера сильнее приглашения: снять его может только модератор.
|
// Бан сервера сильнее приглашения: снять его может только модератор.
|
||||||
if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
|
if user != nil {
|
||||||
return nil, humaError(err)
|
if banned, err := s.store.IsGuildBanned(ctx, invite.GuildID, user.ID); err != nil {
|
||||||
} else if banned {
|
return nil, humaError(err)
|
||||||
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
|
} else if banned {
|
||||||
|
return nil, humaErrorStatus(http.StatusForbidden, "guild.banned", "you are banned from this guild")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
|
if invite.ExpiresAt != nil && invite.ExpiresAt.Before(time.Now().UTC()) {
|
||||||
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
|
return nil, humaErrorStatus(http.StatusGone, "invite.expired", "invite has expired")
|
||||||
@@ -362,11 +375,14 @@ func (s *Server) invitePayload(invite *store.Invite) invitePayload {
|
|||||||
if invite.ExpiresAt != nil {
|
if invite.ExpiresAt != nil {
|
||||||
payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339)
|
payload.ExpiresAt = invite.ExpiresAt.UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
if invite.BackgroundFileID != nil {
|
||||||
|
payload.BackgroundFileID = formatSnowflake(*invite.BackgroundFileID)
|
||||||
|
}
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
// inviteOutput собирает приглашение вместе с карточкой сервера для страницы
|
// inviteOutput собирает приглашение вместе с карточкой сервера для страницы
|
||||||
// предпросмотра (AGENT.md 7.9).
|
// предпросмотра (AGENT.md 7.9). `user` может быть nil — гость по ссылке.
|
||||||
func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput {
|
func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *store.Invite) *inviteOutput {
|
||||||
output := &inviteOutput{}
|
output := &inviteOutput{}
|
||||||
output.Body.Invite = s.invitePayload(invite)
|
output.Body.Invite = s.invitePayload(invite)
|
||||||
@@ -390,8 +406,21 @@ func (s *Server) inviteOutput(ctx context.Context, user *store.User, invite *sto
|
|||||||
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil {
|
||||||
output.Body.Guild.MemberCount = count
|
output.Body.Guild.MemberCount = count
|
||||||
}
|
}
|
||||||
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
|
if user != nil {
|
||||||
output.Body.Guild.IsMember = true
|
if _, err := s.store.GetGuildMember(ctx, guild.ID, user.ID); err == nil {
|
||||||
|
output.Body.Guild.IsMember = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Пригласившего показываем по имени: это часть карточки приглашения.
|
||||||
|
if invite.CreatorID != nil {
|
||||||
|
if creator, err := s.store.GetUser(ctx, *invite.CreatorID); err == nil {
|
||||||
|
output.Body.Guild.Inviter.ID = formatSnowflake(creator.ID)
|
||||||
|
output.Body.Guild.Inviter.Username = creator.Username
|
||||||
|
output.Body.Guild.Inviter.DisplayName = creator.DisplayName
|
||||||
|
if creator.AvatarFileID != nil {
|
||||||
|
output.Body.Guild.Inviter.AvatarFileID = formatSnowflake(*creator.AvatarFileID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
@@ -412,3 +441,113 @@ func newInviteCode() (string, error) {
|
|||||||
}
|
}
|
||||||
return builder.String(), nil
|
return builder.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxInviteBackgroundSize — предел размера переопределения splash (AGENT.md 7.7).
|
||||||
|
const maxInviteBackgroundSize = 5 << 20
|
||||||
|
|
||||||
|
// registerInviteBackgroundRoutes описывает картинку приглашения: создатель
|
||||||
|
// приглашения или MANAGE_GUILD сервера может задать своё оформление страницы
|
||||||
|
// (AGENT.md 7.9). Ручка multipart, поэтому живёт на роутере.
|
||||||
|
func (s *Server) registerInviteBackgroundRoutes(router chi.Router) {
|
||||||
|
router.Post("/invites/{code}/background", s.handleInviteBackgroundUpload)
|
||||||
|
router.Delete("/invites/{code}/background", s.handleInviteBackgroundDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInviteBackgroundUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _, ok := s.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user)
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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 > maxInviteBackgroundSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background is too large")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(io.LimitReader(file, maxInviteBackgroundSize+1))
|
||||||
|
if err != nil || int64(len(data)) > maxInviteBackgroundSize {
|
||||||
|
httpxWriteJSONError(w, http.StatusRequestEntityTooLarge, "file.too_large", "invite background 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", "invite background must be an image")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Старую картинку удаляем: загрузки не должны копиться (AGENT.md 7.7).
|
||||||
|
if previous := invite.BackgroundFileID; previous != nil && *previous != 0 {
|
||||||
|
s.deleteStoredFile(ctx, *previous)
|
||||||
|
}
|
||||||
|
stored, err := s.saveUpload(ctx, store.File{
|
||||||
|
UploaderID: &user.ID,
|
||||||
|
GuildID: &invite.GuildID,
|
||||||
|
Filename: sanitizeFilename(header.Filename),
|
||||||
|
ContentType: contentType,
|
||||||
|
Purpose: "invite_background",
|
||||||
|
}, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, err := s.store.SetInviteBackground(ctx, invite.Code, &stored.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.deleteStoredFile(ctx, stored.ID)
|
||||||
|
writeHumaAPIError(w, humaError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, invite.GuildID, "invite.background", "invite", nil, invite.Code)
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleInviteBackgroundDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _, ok := s.authenticate(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
invite, err := s.requireInviteManage(ctx, chi.URLParam(r, "code"), user)
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if previous := invite.BackgroundFileID; previous != nil && *previous != 0 {
|
||||||
|
s.deleteStoredFile(ctx, *previous)
|
||||||
|
}
|
||||||
|
updated, err := s.store.SetInviteBackground(ctx, invite.Code, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeHumaAPIError(w, humaError(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, user, invite.GuildID, "invite.background_remove", "invite", nil, invite.Code)
|
||||||
|
httpxWriteJSON(w, http.StatusOK, map[string]any{"invite": s.invitePayload(updated)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireInviteManage достаёт приглашение и проверяет право менять его
|
||||||
|
// оформление: создатель или MANAGE_GUILD сервера (AGENT.md 7.9).
|
||||||
|
func (s *Server) requireInviteManage(ctx context.Context, code string, user *store.User) (*store.Invite, error) {
|
||||||
|
invite, err := s.store.GetInvite(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, humaError(err)
|
||||||
|
}
|
||||||
|
if invite.CreatorID != nil && *invite.CreatorID == user.ID {
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
if _, _, err := s.requireGuildPermission(ctx, formatSnowflake(invite.GuildID), user, permissions.ManageGuild); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return invite, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,6 +107,16 @@ func (s *Server) registerMessageRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 секунд на комнату и пользователя,
|
// Антиспам-лимит: 5 сообщений за 5 секунд на комнату и пользователя,
|
||||||
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
// администратор инстанса лимит обходит (AGENT.md 8.6, 7.19).
|
||||||
if !user.IsInstanceAdmin {
|
if !user.IsInstanceAdmin {
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
reverse, err := s.store.GetRelationship(ctx, target.ID, user.ID)
|
||||||
switch {
|
switch {
|
||||||
@@ -252,6 +258,65 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
return newOKOutput(), nil
|
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{
|
huma.Register(api, huma.Operation{
|
||||||
OperationID: "openDirectChannel",
|
OperationID: "openDirectChannel",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -280,6 +345,12 @@ func (s *Server) registerSocialRoutes(api huma.API) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, humaError(err)
|
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)
|
channel, err := s.store.FindDMChannel(ctx, user.ID, recipientID)
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
channel, err = s.store.CreateDMChannel(ctx, user.ID, recipientID)
|
||||||
|
|||||||
@@ -521,3 +521,179 @@ func TestMembersCarrySystemBadges(t *testing.T) {
|
|||||||
t.Fatalf("участник %s не найден: %s", ownerID, list.Body.String())
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteBackgroundAndPreview(t *testing.T) {
|
||||||
|
srv, owner, member, guildID, _ := cosmeticsFixture(t)
|
||||||
|
created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guildID+"/invites",
|
||||||
|
`{"max_uses":5,"max_age_seconds":600}`, owner)
|
||||||
|
code := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
} `json:"invite"`
|
||||||
|
}](t, created).Invite.Code
|
||||||
|
if code == "" {
|
||||||
|
t.Fatalf("приглашение не создано: %s", created.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предпросмотр: карточка сервера и пригласивший.
|
||||||
|
preview := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
|
||||||
|
body := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
BackgroundFileID string `json:"background_file_id"`
|
||||||
|
} `json:"invite"`
|
||||||
|
Guild struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsMember bool `json:"is_member"`
|
||||||
|
Inviter struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
} `json:"inviter"`
|
||||||
|
} `json:"guild"`
|
||||||
|
}](t, preview)
|
||||||
|
if body.Guild.Name == "" || body.Guild.Inviter.Username == "" {
|
||||||
|
t.Fatalf("предпросмотр без карточки: %s", preview.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка переопределения splash: multipart, картинка.
|
||||||
|
var buf bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&buf)
|
||||||
|
part, err := writer.CreateFormFile("file", "invite.png")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multipart: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := part.Write(pngBytes()); err != nil {
|
||||||
|
t.Fatalf("multipart write: %v", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("multipart close: %v", err)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost,
|
||||||
|
"/api/v1/invites/"+code+"/background", &buf)
|
||||||
|
request.Header.Set("Content-Type", writer.FormDataContentType())
|
||||||
|
request.AddCookie(owner)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
srv.Handler().ServeHTTP(rec, request)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("загрузка картинки приглашения = %d (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
uploaded := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
BackgroundFileID string `json:"background_file_id"`
|
||||||
|
} `json:"invite"`
|
||||||
|
}](t, rec)
|
||||||
|
if uploaded.Invite.BackgroundFileID == "" {
|
||||||
|
t.Fatalf("переопределение не сохранилось: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карточка приглашения отдаёт переопределение и без сессии не работает.
|
||||||
|
after := doJSON(t, srv, http.MethodGet, "/api/v1/invites/"+code, "", member)
|
||||||
|
fileID := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
BackgroundFileID string `json:"background_file_id"`
|
||||||
|
} `json:"invite"`
|
||||||
|
}](t, after).Invite.BackgroundFileID
|
||||||
|
if fileID != uploaded.Invite.BackgroundFileID {
|
||||||
|
t.Fatalf("переопределение не видно в предпросмотре: %s", after.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Посторонний не может менять оформление чужого приглашения.
|
||||||
|
outsider := registerAndLogin(t, srv, "invite_guest", "invite-guest@example.com")
|
||||||
|
denied := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", outsider)
|
||||||
|
if denied.Code != http.StatusForbidden && denied.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("снятие чужим = %d, ожидался отказ (%s)", denied.Code, denied.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Снятие переопределения создателем.
|
||||||
|
cleared := doJSON(t, srv, http.MethodDelete, "/api/v1/invites/"+code+"/background", "", owner)
|
||||||
|
if cleared.Code != http.StatusOK {
|
||||||
|
t.Fatalf("снятие переопределения = %d (%s)", cleared.Code, cleared.Body.String())
|
||||||
|
}
|
||||||
|
final := decodeResponse[struct {
|
||||||
|
Invite struct {
|
||||||
|
BackgroundFileID string `json:"background_file_id"`
|
||||||
|
} `json:"invite"`
|
||||||
|
}](t, cleared)
|
||||||
|
if final.Invite.BackgroundFileID != "" {
|
||||||
|
t.Fatalf("переопределение не снято: %s", cleared.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se
|
|||||||
s.registerWebhookRoutes(s.api, apiRouter)
|
s.registerWebhookRoutes(s.api, apiRouter)
|
||||||
s.registerCosmeticRoutes(s.api, apiRouter)
|
s.registerCosmeticRoutes(s.api, apiRouter)
|
||||||
s.registerChannelBackgroundRoutes(apiRouter)
|
s.registerChannelBackgroundRoutes(apiRouter)
|
||||||
|
s.registerInviteBackgroundRoutes(apiRouter)
|
||||||
s.registerVoiceWebhook(apiRouter)
|
s.registerVoiceWebhook(apiRouter)
|
||||||
}
|
}
|
||||||
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
apiRouter.Get("/openapi.json", s.handleOpenAPI)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ type Invite struct {
|
|||||||
MaxAgeSec int
|
MaxAgeSec int
|
||||||
ExpiresAt *time.Time
|
ExpiresAt *time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
// BackgroundFileID — переопределение splash для этого приглашения
|
||||||
|
// (AGENT.md 7.9): страница приглашения показывает свою картинку.
|
||||||
|
BackgroundFileID *uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInviteParams — параметры приглашения.
|
// CreateInviteParams — параметры приглашения.
|
||||||
@@ -30,7 +33,7 @@ type CreateInviteParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
|
const inviteColumns = `code, guild_id, channel_id, creator_id, max_uses, uses,
|
||||||
max_age_sec, expires_at, created_at`
|
max_age_sec, expires_at, created_at, background_file_id`
|
||||||
|
|
||||||
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
|
// CreateInvite сохраняет приглашение с кодом, лимитом использований и сроком.
|
||||||
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
|
func (s *Store) CreateInvite(ctx context.Context, params CreateInviteParams) (*Invite, error) {
|
||||||
@@ -54,6 +57,19 @@ func (s *Store) GetInvite(ctx context.Context, code string) (*Invite, error) {
|
|||||||
return scanInvite(row)
|
return scanInvite(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInviteBackground задаёт или снимает переопределение splash приглашения.
|
||||||
|
func (s *Store) SetInviteBackground(ctx context.Context, code string, fileID *uint64) (*Invite, error) {
|
||||||
|
result, err := s.writer.ExecContext(ctx,
|
||||||
|
`UPDATE invites SET background_file_id = ? WHERE code = ?`, nullableID(fileID), code)
|
||||||
|
if err != nil {
|
||||||
|
return nil, mapError(err)
|
||||||
|
}
|
||||||
|
if affected, err := result.RowsAffected(); err == nil && affected == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return s.GetInvite(ctx, code)
|
||||||
|
}
|
||||||
|
|
||||||
// ListGuildInvites возвращает приглашения сервера (для панели управления).
|
// ListGuildInvites возвращает приглашения сервера (для панели управления).
|
||||||
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
|
func (s *Store) ListGuildInvites(ctx context.Context, guildID uint64, limit int) ([]Invite, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
@@ -132,19 +148,21 @@ func (s *Store) ListPublicGuilds(ctx context.Context, limit int) ([]Guild, error
|
|||||||
|
|
||||||
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
|
func scanInvite(scanner interface{ Scan(...any) error }) (*Invite, error) {
|
||||||
var (
|
var (
|
||||||
invite Invite
|
invite Invite
|
||||||
channelID sql.NullInt64
|
channelID sql.NullInt64
|
||||||
creatorID sql.NullInt64
|
creatorID sql.NullInt64
|
||||||
expiresAt sql.NullString
|
expiresAt sql.NullString
|
||||||
createdAt string
|
createdAt string
|
||||||
|
background sql.NullInt64
|
||||||
)
|
)
|
||||||
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
|
err := scanner.Scan(&invite.Code, &invite.GuildID, &channelID, &creatorID, &invite.MaxUses,
|
||||||
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt)
|
&invite.Uses, &invite.MaxAgeSec, &expiresAt, &createdAt, &background)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, mapError(err)
|
return nil, mapError(err)
|
||||||
}
|
}
|
||||||
invite.ChannelID = optionalID(channelID)
|
invite.ChannelID = optionalID(channelID)
|
||||||
invite.CreatorID = optionalID(creatorID)
|
invite.CreatorID = optionalID(creatorID)
|
||||||
|
invite.BackgroundFileID = optionalID(background)
|
||||||
if expiresAt.Valid {
|
if expiresAt.Valid {
|
||||||
value := parseTimestamp(expiresAt.String)
|
value := parseTimestamp(expiresAt.String)
|
||||||
invite.ExpiresAt = &value
|
invite.ExpiresAt = &value
|
||||||
|
|||||||
@@ -188,6 +188,20 @@ export async function acceptFriendRequest(userId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `PUT /users/@me/blocks/{id}` — заблокировать (AGENT.md 7.2). */
|
||||||
|
export async function blockUser(userId: string): Promise<void> {
|
||||||
|
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DELETE /users/@me/blocks/{id}` — снять блокировку. */
|
||||||
|
export async function unblockUser(userId: string): Promise<void> {
|
||||||
|
await request<{ ok: true }>(`/users/@me/blocks/${encodeURIComponent(userId)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
|
* `DELETE /users/@me/relationships/{id}` — удалить из друзей, отклонить или
|
||||||
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
* отменить заявку. Отдельной ручки блокировки на сервере нет: блокировка —
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ export interface InviteGuildPreview {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon_file_id?: string;
|
icon_file_id?: string;
|
||||||
|
/** Кто позвал: имя видно на странице приглашения (AGENT.md 7.9). */
|
||||||
|
inviter?: {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
avatar_file_id?: string;
|
||||||
|
};
|
||||||
/** Оформление сервера для страницы приглашения (AGENT.md 7.4). */
|
/** Оформление сервера для страницы приглашения (AGENT.md 7.4). */
|
||||||
banner_file_id?: string;
|
banner_file_id?: string;
|
||||||
splash_file_id?: string;
|
splash_file_id?: string;
|
||||||
@@ -25,6 +32,8 @@ export interface InviteGuildPreview {
|
|||||||
export interface Invite {
|
export interface Invite {
|
||||||
code: string;
|
code: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
|
/** Переопределение splash для этого приглашения (AGENT.md 7.9). */
|
||||||
|
background_file_id?: string;
|
||||||
channel_id?: string;
|
channel_id?: string;
|
||||||
creator_id?: string;
|
creator_id?: string;
|
||||||
max_uses: number;
|
max_uses: number;
|
||||||
@@ -40,6 +49,8 @@ export interface InvitePreview {
|
|||||||
code: string;
|
code: string;
|
||||||
guild_id: string;
|
guild_id: string;
|
||||||
guild: InviteGuildPreview;
|
guild: InviteGuildPreview;
|
||||||
|
/** Переопределение splash самого приглашения (приоритетнее splash сервера). */
|
||||||
|
background_file_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const inviteQueryKey = (code: string) => ['invites', code] as const;
|
export const inviteQueryKey = (code: string) => ['invites', code] as const;
|
||||||
@@ -113,6 +124,7 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
|
|||||||
if (guild === null || guildId === undefined) {
|
if (guild === null || guildId === undefined) {
|
||||||
throw new Error('malformed invite payload');
|
throw new Error('malformed invite payload');
|
||||||
}
|
}
|
||||||
|
const background = asString(invite?.['background_file_id']);
|
||||||
const preview: InvitePreview = {
|
const preview: InvitePreview = {
|
||||||
code: asString(invite?.['code']) ?? code,
|
code: asString(invite?.['code']) ?? code,
|
||||||
guild_id: guildId,
|
guild_id: guildId,
|
||||||
@@ -140,9 +152,26 @@ export async function fetchInvite(code: string, signal?: AbortSignal): Promise<I
|
|||||||
if (icon !== undefined) {
|
if (icon !== undefined) {
|
||||||
preview.guild.icon_file_id = icon;
|
preview.guild.icon_file_id = icon;
|
||||||
}
|
}
|
||||||
|
const inviter = asRecord(guild['inviter']);
|
||||||
|
const inviterId = asString(inviter?.['id']);
|
||||||
|
if (inviter !== null && inviterId !== undefined) {
|
||||||
|
const inviterUser: NonNullable<InviteGuildPreview['inviter']> = {
|
||||||
|
id: inviterId,
|
||||||
|
username: asString(inviter['username']) ?? '',
|
||||||
|
display_name: asString(inviter['display_name']) ?? '',
|
||||||
|
};
|
||||||
|
const inviterAvatar = asString(inviter['avatar_file_id']);
|
||||||
|
if (inviterAvatar !== undefined) {
|
||||||
|
inviterUser.avatar_file_id = inviterAvatar;
|
||||||
|
}
|
||||||
|
preview.guild.inviter = inviterUser;
|
||||||
|
}
|
||||||
if (description !== undefined) {
|
if (description !== undefined) {
|
||||||
preview.guild.description = description;
|
preview.guild.description = description;
|
||||||
}
|
}
|
||||||
|
if (background !== undefined) {
|
||||||
|
preview.background_file_id = background;
|
||||||
|
}
|
||||||
return preview;
|
return preview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useRef, useState } from 'react';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { SystemBadges } from '@/components/profile/SystemBadges';
|
import { SystemBadges } from '@/components/profile/SystemBadges';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
@@ -33,6 +33,11 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
const nameRef = useRef<HTMLButtonElement | null>(null);
|
const nameRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const menuRef = useRef<HTMLButtonElement | null>(null);
|
const menuRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
const block = useMutation({
|
||||||
|
mutationFn: () => blockUser(friend.user_id),
|
||||||
|
onSuccess: () => reload(),
|
||||||
|
});
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: () => removeRelationship(friend.user_id),
|
mutationFn: () => removeRelationship(friend.user_id),
|
||||||
onSuccess: () => reload(),
|
onSuccess: () => reload(),
|
||||||
@@ -180,11 +185,12 @@ export function FriendRow({ friend }: { friend: RelationshipUser }) {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
danger
|
danger
|
||||||
icon="⛔"
|
icon="⛔"
|
||||||
disabled={remove.isPending}
|
data-testid={`friend-block-${friend.user_id}`}
|
||||||
|
disabled={block.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
// Отдельной ручки блокировки нет: блокировка — тот же DELETE.
|
// Блокировка запрещает личные сообщения и снимает дружбу (AGENT.md 7.2).
|
||||||
remove.mutate();
|
block.mutate();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('friends.list.block')}
|
{t('friends.list.block')}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { useEffect, useId, useState } from 'react';
|
|||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { FriendRow } from '@/components/friends/FriendRow';
|
||||||
|
import { FramedAvatar, Nickname } from '@/components/profile/Cosmetics';
|
||||||
import { InviteJoin } from '@/components/friends/InviteJoin';
|
import { InviteJoin } from '@/components/friends/InviteJoin';
|
||||||
import { UserSearch } from '@/components/friends/UserSearch';
|
import { UserSearch } from '@/components/friends/UserSearch';
|
||||||
import { Avatar } from '@/components/ui/Avatar';
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
@@ -29,6 +35,7 @@ export function FriendsView() {
|
|||||||
const friends = useFriendsStore((state) => state.friends);
|
const friends = useFriendsStore((state) => state.friends);
|
||||||
const incoming = useFriendsStore((state) => state.incoming);
|
const incoming = useFriendsStore((state) => state.incoming);
|
||||||
const outgoing = useFriendsStore((state) => state.outgoing);
|
const outgoing = useFriendsStore((state) => state.outgoing);
|
||||||
|
const blocked = useFriendsStore((state) => state.blocked);
|
||||||
const status = useFriendsStore((state) => state.status);
|
const status = useFriendsStore((state) => state.status);
|
||||||
const error = useFriendsStore((state) => state.error);
|
const error = useFriendsStore((state) => state.error);
|
||||||
const load = useFriendsStore((state) => state.load);
|
const load = useFriendsStore((state) => state.load);
|
||||||
@@ -44,6 +51,12 @@ export function FriendsView() {
|
|||||||
void load();
|
void load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
|
// Снятие блокировки: список перечитывается, человек снова может писать.
|
||||||
|
const unblock = useMutation({
|
||||||
|
mutationFn: (userId: string) => unblockUser(userId),
|
||||||
|
onSuccess: () => reload(),
|
||||||
|
});
|
||||||
|
|
||||||
const respond = useMutation({
|
const respond = useMutation({
|
||||||
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
|
mutationFn: ({ kind, userId }: { kind: 'accept' | 'decline' | 'cancel'; userId: string }) =>
|
||||||
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
|
kind === 'accept' ? acceptFriendRequest(userId) : removeRelationship(userId),
|
||||||
@@ -222,7 +235,49 @@ export function FriendsView() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{blocked.length === 0 ? null : (
|
||||||
|
<section aria-label={t('friends.blocked.section')} className="mt-4">
|
||||||
|
<h3 className="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('friends.blocked.section', { count: blocked.length })}
|
||||||
|
</h3>
|
||||||
|
<ul className="gl-stagger mt-1 flex flex-col gap-0.5" data-testid="friends-blocked">
|
||||||
|
{blocked.map((user) => (
|
||||||
|
<li
|
||||||
|
key={user.user_id}
|
||||||
|
data-testid={`blocked-row-${user.user_id}`}
|
||||||
|
className="flex items-center gap-3 rounded-[var(--radius-sm)] px-2 py-1.5 hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
<FramedAvatar
|
||||||
|
name={displayName(user)}
|
||||||
|
seed={user.user_id}
|
||||||
|
fileId={user.avatar_file_id}
|
||||||
|
size="md"
|
||||||
|
cosmetics={user.cosmetics}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<Nickname
|
||||||
|
name={displayName(user)}
|
||||||
|
cosmetics={user.cosmetics}
|
||||||
|
className="text-sm font-medium"
|
||||||
|
/>
|
||||||
|
<span className="block truncate text-xs text-fg-muted">@{user.username}</span>
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
data-testid={`blocked-unblock-${user.user_id}`}
|
||||||
|
disabled={unblock.isPending}
|
||||||
|
onClick={() => unblock.mutate(user.user_id)}
|
||||||
|
>
|
||||||
|
{t('friends.blocked.unblock')}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
|
{respond.isError ? <ErrorNotice className="mt-3" error={respond.error} /> : null}
|
||||||
|
{unblock.isError ? <ErrorNotice className="mt-3" error={unblock.error} /> : null}
|
||||||
|
|
||||||
<InviteJoin className="mt-8" />
|
<InviteJoin className="mt-8" />
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -418,6 +418,11 @@
|
|||||||
"title": "Conversation unavailable",
|
"title": "Conversation unavailable",
|
||||||
"body": "This direct conversation was not found: it may be deleted or closed to you."
|
"body": "This direct conversation was not found: it may be deleted or closed to you."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"section": "Blocked ({{count}})",
|
||||||
|
"unblock": "Unblock",
|
||||||
|
"alreadyBlocked": "Blocked"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
@@ -429,7 +434,17 @@
|
|||||||
"join": "Join",
|
"join": "Join",
|
||||||
"joining": "Joining…",
|
"joining": "Joining…",
|
||||||
"members": "Members: {{count}}",
|
"members": "Members: {{count}}",
|
||||||
"alreadyMember": "You are already on this server"
|
"alreadyMember": "You are already on this server",
|
||||||
|
"invitedTo": "You are invited to a server",
|
||||||
|
"inviter": "Invited by {{name}}",
|
||||||
|
"accept": "Join server",
|
||||||
|
"open": "Open server",
|
||||||
|
"loginHint": "Log in to join — we will bring you back to this page.",
|
||||||
|
"login": "Log in",
|
||||||
|
"register": "Register",
|
||||||
|
"toLogin": "Log in",
|
||||||
|
"notFoundTitle": "This invite does not work",
|
||||||
|
"notFound": "The link is invalid, expired or revoked. Ask for a new one."
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"log": "Message log of {{name}}",
|
"log": "Message log of {{name}}",
|
||||||
|
|||||||
@@ -418,6 +418,11 @@
|
|||||||
"title": "Беседа недоступна",
|
"title": "Беседа недоступна",
|
||||||
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
|
"body": "Личная беседа не найдена: возможно, она удалена или доступ к ней закрыт."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"blocked": {
|
||||||
|
"section": "Заблокированные ({{count}})",
|
||||||
|
"unblock": "Разблокировать",
|
||||||
|
"alreadyBlocked": "Заблокирован"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"invites": {
|
"invites": {
|
||||||
@@ -429,7 +434,17 @@
|
|||||||
"join": "Присоединиться",
|
"join": "Присоединиться",
|
||||||
"joining": "Присоединяемся…",
|
"joining": "Присоединяемся…",
|
||||||
"members": "Участников: {{count}}",
|
"members": "Участников: {{count}}",
|
||||||
"alreadyMember": "Вы уже на этом сервере"
|
"alreadyMember": "Вы уже на этом сервере",
|
||||||
|
"invitedTo": "Вас приглашают на сервер",
|
||||||
|
"inviter": "Приглашает {{name}}",
|
||||||
|
"accept": "Присоединиться",
|
||||||
|
"open": "Открыть сервер",
|
||||||
|
"loginHint": "Войдите, чтобы присоединиться — вернём вас на эту страницу.",
|
||||||
|
"login": "Войти",
|
||||||
|
"register": "Регистрация",
|
||||||
|
"toLogin": "Войти",
|
||||||
|
"notFoundTitle": "Приглашение не работает",
|
||||||
|
"notFound": "Ссылка недействительна, истекла или отозвана. Попросите новую."
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"log": "Лента сообщений комнаты {{name}}",
|
"log": "Лента сообщений комнаты {{name}}",
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { acceptInvite, fetchInvite, inviteQueryKey } from '@/api/invites';
|
||||||
|
import { instanceQueryKey } from '@/api/instance';
|
||||||
|
import { myGuildsQueryKey } from '@/api/users';
|
||||||
|
import { Avatar } from '@/components/ui/Avatar';
|
||||||
|
import { AnimatedImage } from '@/components/ui/AnimatedImage';
|
||||||
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
|
import { Button } from '@/components/ui/primitives';
|
||||||
|
import { SkeletonLines } from '@/components/ui/Skeleton';
|
||||||
|
import { fileContentUrl } from '@/api/files';
|
||||||
|
import { roleColorHex } from '@/lib/format';
|
||||||
|
import { isUnauthorized } from '@/lib/http';
|
||||||
|
import { useCurrentUser } from '@/lib/hooks';
|
||||||
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
import { useSessionStore } from '@/stores/session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница приглашения `/invite/{code}` (AGENT.md 7.9, критерий Фазы 4):
|
||||||
|
* показывает splash сервера или переопределение приглашения, карточку сервера,
|
||||||
|
* кто позвал, и присоединяет одним нажатием. Доступна без входа — гость видит
|
||||||
|
* предпросмотр, а кнопка ведёт на вход с возвратом на эту же страницу.
|
||||||
|
*/
|
||||||
|
export default function InvitePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const params = useParams<{ code?: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const describeError = useApiErrorMessage();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const guilds = useSessionStore((state) => state.guilds);
|
||||||
|
const [joinError, setJoinError] = useState<unknown>(null);
|
||||||
|
const code = params.code ?? '';
|
||||||
|
|
||||||
|
// Гость (401) видит карточку приглашения: сервер отдаёт её без сессии.
|
||||||
|
const guest = currentUser.isError && isUnauthorized(currentUser.error);
|
||||||
|
const preview = useQuery({
|
||||||
|
queryKey: inviteQueryKey(code),
|
||||||
|
queryFn: ({ signal }) => fetchInvite(code, signal),
|
||||||
|
enabled: code !== '' && (currentUser.isSuccess || guest),
|
||||||
|
retry: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const join = useMutation({
|
||||||
|
mutationFn: () => acceptInvite(code),
|
||||||
|
onSuccess: (guildId) => {
|
||||||
|
setJoinError(null);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
|
||||||
|
void navigate(`/app/${guildId}`);
|
||||||
|
},
|
||||||
|
onError: (cause: unknown) => setJoinError(cause),
|
||||||
|
});
|
||||||
|
|
||||||
|
const guild = preview.data?.guild;
|
||||||
|
const memberGuild = useMemo(
|
||||||
|
() => (guild === undefined ? undefined : guilds.find((item) => item.id === guild.id)),
|
||||||
|
[guild, guilds],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Фон страницы: переопределение приглашения → splash → баннер сервера.
|
||||||
|
const backgroundFileId =
|
||||||
|
preview.data?.background_file_id ?? guild?.splash_file_id ?? guild?.banner_file_id;
|
||||||
|
const accent = guild?.accent_color;
|
||||||
|
const accentHex = accent === undefined || accent === 0 ? null : roleColorHex(accent);
|
||||||
|
|
||||||
|
const inviterName =
|
||||||
|
guild?.inviter === undefined
|
||||||
|
? ''
|
||||||
|
: guild.inviter.display_name === ''
|
||||||
|
? guild.inviter.username
|
||||||
|
: guild.inviter.display_name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="relative flex min-h-dvh items-center justify-center overflow-hidden bg-bg px-4 py-10">
|
||||||
|
{backgroundFileId === undefined ? (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0"
|
||||||
|
style={
|
||||||
|
accentHex === null
|
||||||
|
? undefined
|
||||||
|
: { background: `radial-gradient(circle at 50% 0%, ${accentHex}55, transparent 60%)` }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div aria-hidden="true" className="absolute inset-0">
|
||||||
|
<AnimatedImage
|
||||||
|
src={fileContentUrl(backgroundFileId)}
|
||||||
|
className="h-full w-full object-cover opacity-50"
|
||||||
|
/>
|
||||||
|
<span className="absolute inset-0 bg-bg/70" data-testid="invite-background" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="relative w-full max-w-lg rounded-[var(--radius-lg)] border border-border/60 bg-surface-1/95 p-6 shadow-[var(--shadow-3)] gl-pop-in"
|
||||||
|
data-testid="invite-page"
|
||||||
|
>
|
||||||
|
{code === '' ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||||
|
) : preview.isError || (currentUser.isError && !guest) ? (
|
||||||
|
<>
|
||||||
|
<h1 className="text-lg font-semibold">{t('invites.notFoundTitle')}</h1>
|
||||||
|
<p className="mt-2 text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||||
|
<p className="mt-3 text-sm text-danger">{describeError(preview.error)}</p>
|
||||||
|
<Button className="mt-4" onClick={() => void navigate('/login')}>
|
||||||
|
{t('invites.toLogin')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : preview.isPending || currentUser.isPending ? (
|
||||||
|
<SkeletonLines rows={4} />
|
||||||
|
) : guild === undefined ? (
|
||||||
|
<p className="text-sm text-fg-muted">{t('invites.notFound')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-fg-muted">
|
||||||
|
{t('invites.invitedTo')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<Avatar
|
||||||
|
name={guild.name}
|
||||||
|
seed={guild.id}
|
||||||
|
fileId={guild.icon_file_id}
|
||||||
|
size="lg"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h1 className="truncate text-xl font-semibold" data-testid="invite-guild-name">
|
||||||
|
{guild.name}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-fg-muted">
|
||||||
|
{t('invites.members', { count: guild.member_count })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{guild.description === undefined || guild.description === '' ? null : (
|
||||||
|
<p className="mt-4 text-sm text-fg-muted">{guild.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{guild.inviter === undefined ? null : (
|
||||||
|
<div className="mt-4 flex items-center gap-2" data-testid="invite-inviter">
|
||||||
|
<Avatar
|
||||||
|
name={inviterName}
|
||||||
|
seed={guild.inviter.id}
|
||||||
|
fileId={guild.inviter.avatar_file_id}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-fg-muted">
|
||||||
|
{t('invites.inviter', { name: inviterName })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentUser.data === undefined || guest ? (
|
||||||
|
// Гость видит предпросмотр: вход вернёт его на эту же страницу.
|
||||||
|
<div className="mt-6 flex flex-col gap-2">
|
||||||
|
<p className="text-sm text-fg-muted">{t('invites.loginHint')}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="invite-login"
|
||||||
|
onClick={() => void navigate(`/login?next=/invite/${encodeURIComponent(code)}`)}
|
||||||
|
>
|
||||||
|
{t('invites.login')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
void navigate(`/register?next=/invite/${encodeURIComponent(code)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t('invites.register')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : memberGuild !== undefined || guild.is_member ? (
|
||||||
|
<Button
|
||||||
|
className="mt-6"
|
||||||
|
data-testid="invite-open"
|
||||||
|
onClick={() => void navigate(`/app/${guild.id}`)}
|
||||||
|
>
|
||||||
|
{t('invites.open')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="mt-6"
|
||||||
|
data-testid="invite-accept"
|
||||||
|
disabled={join.isPending}
|
||||||
|
onClick={() => join.mutate()}
|
||||||
|
>
|
||||||
|
{t(join.isPending ? 'invites.joining' : 'invites.accept')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{joinError === null ? null : <ErrorNotice className="mt-3" error={joinError} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { login } from '@/api/auth';
|
import { login } from '@/api/auth';
|
||||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||||
@@ -30,8 +30,13 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [totpCode, setTotpCode] = useState('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
const [needsTotp, setNeedsTotp] = useState(false);
|
const [needsTotp, setNeedsTotp] = useState(false);
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const from = (location.state as LocationState | null)?.from ?? '/app';
|
// Возврат после входа: `?next=` (страница приглашения) важнее состояния
|
||||||
|
// перехода из AuthGuard, но только для внутренних путей.
|
||||||
|
const next = searchParams.get('next');
|
||||||
|
const stateFrom = (location.state as LocationState | null)?.from ?? null;
|
||||||
|
const from = next !== null && next.startsWith('/') ? next : (stateFrom ?? '/app');
|
||||||
|
|
||||||
const submit = useMutation({
|
const submit = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
|||||||
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
export const RegisterPage = lazy(() => import('@/pages/RegisterPage'));
|
||||||
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
export const OnboardingPage = lazy(() => import('@/pages/OnboardingPage'));
|
||||||
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
export const StatusPage = lazy(() => import('@/pages/StatusPage'));
|
||||||
|
export const InvitePage = lazy(() => import('@/pages/InvitePage'));
|
||||||
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
export const AppLayout = lazy(() => import('@/pages/app/AppLayout'));
|
||||||
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
export const GuildView = lazy(() => import('@/pages/app/GuildView'));
|
||||||
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));
|
export const FriendsPage = lazy(() => import('@/pages/app/FriendsPage'));
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
kickGuildMember,
|
kickGuildMember,
|
||||||
timeoutGuildMember,
|
timeoutGuildMember,
|
||||||
} from '@/api/moderation';
|
} from '@/api/moderation';
|
||||||
|
import { blockUser } from '@/api/friends';
|
||||||
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
import { Menu, MenuGroupLabel, MenuItem, MenuSeparator } from '@/components/ui/Menu';
|
||||||
|
import { useFriendsStore } from '@/stores/friends';
|
||||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||||
|
|
||||||
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
|
/** Пресеты тайм-аута в минутах: 0 — снять ограничение. */
|
||||||
@@ -64,6 +66,17 @@ export function MemberModerationMenu({
|
|||||||
close();
|
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({
|
const kick = useMutation({
|
||||||
mutationFn: () => kickGuildMember(guildId, userId),
|
mutationFn: () => kickGuildMember(guildId, userId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -162,6 +175,16 @@ export function MemberModerationMenu({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Личная блокировка не зависит от прав на сервере (AGENT.md 7.2). */}
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
disabled={pending || blocked}
|
||||||
|
data-testid={`member-block-${userId}`}
|
||||||
|
onClick={() => block.mutate()}
|
||||||
|
>
|
||||||
|
{t(blocked ? 'friends.blocked.alreadyBlocked' : 'friends.list.block')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
{error === null || error === undefined ? null : (
|
{error === null || error === undefined ? null : (
|
||||||
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
<p className="px-2 py-1 text-xs text-danger">{describeError(error)}</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AppLayout,
|
AppLayout,
|
||||||
IndexRedirect,
|
IndexRedirect,
|
||||||
FriendsPage,
|
FriendsPage,
|
||||||
|
InvitePage,
|
||||||
GuildView,
|
GuildView,
|
||||||
InstanceSettingsPage,
|
InstanceSettingsPage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
@@ -29,6 +30,8 @@ export const routes = [
|
|||||||
{ path: 'login', element: <LoginPage /> },
|
{ path: 'login', element: <LoginPage /> },
|
||||||
{ path: 'register', element: <RegisterPage /> },
|
{ path: 'register', element: <RegisterPage /> },
|
||||||
{ path: 'status', element: <StatusPage /> },
|
{ path: 'status', element: <StatusPage /> },
|
||||||
|
// Страница приглашения: доступна без входа, гость видит предпросмотр.
|
||||||
|
{ path: 'invite/:code', element: <InvitePage /> },
|
||||||
{
|
{
|
||||||
element: <AuthGuard allowIncompleteOnboarding />,
|
element: <AuthGuard allowIncompleteOnboarding />,
|
||||||
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
children: [{ path: 'onboarding', element: <OnboardingPage /> }],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
messagesRoutes,
|
messagesRoutes,
|
||||||
recordedRequests,
|
recordedRequests,
|
||||||
renderApp,
|
renderApp,
|
||||||
|
waitForPage,
|
||||||
type FetchRoute,
|
type FetchRoute,
|
||||||
} from './helpers';
|
} 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findRequest,
|
||||||
|
installFetch,
|
||||||
|
installFailingFetch,
|
||||||
|
json,
|
||||||
|
makeUser,
|
||||||
|
renderApp,
|
||||||
|
resetStores,
|
||||||
|
type FetchRoute,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Страница приглашения `/invite/{code}` (AGENT.md 7.9): splash сервера или
|
||||||
|
* переопределение приглашения, карточка сервера, пригласивший и присоединение.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
function guildPreview(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: 'g-9',
|
||||||
|
name: 'Сообщество',
|
||||||
|
icon_file_id: 'icon-9',
|
||||||
|
splash_file_id: 'splash-9',
|
||||||
|
description: 'Обсуждаем всё подряд',
|
||||||
|
member_count: 12,
|
||||||
|
is_member: false,
|
||||||
|
inviter: { id: 'user-2', username: 'bob', display_name: 'Боб', avatar_file_id: 'av-2' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routes(
|
||||||
|
options: { guild?: Record<string, unknown>; background?: string } = {},
|
||||||
|
): FetchRoute[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/abc123',
|
||||||
|
method: 'POST',
|
||||||
|
response: () => json({ guild_id: 'g-9' }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/abc123',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
invite: {
|
||||||
|
code: 'abc123',
|
||||||
|
guild_id: 'g-9',
|
||||||
|
...(options.background === undefined ? {} : { background_file_id: options.background }),
|
||||||
|
},
|
||||||
|
guild: options.guild ?? guildPreview(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/users/@me', response: () => json({ user }) },
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
{ match: '/api/v1/users/@me/guilds', response: () => json({ guilds: [] }) },
|
||||||
|
{ match: '/api/v1/guilds/g-9/channels', response: () => json({ channels: [] }) },
|
||||||
|
{
|
||||||
|
match: '/api/v1/guilds/g-9',
|
||||||
|
response: () => json({ guild: { id: 'g-9', name: 'Сообщество' } }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStores();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('страница приглашения', () => {
|
||||||
|
it('показывает splash сервера, карточку и пригласившего', async () => {
|
||||||
|
installFetch(routes());
|
||||||
|
renderApp('/invite/abc123');
|
||||||
|
|
||||||
|
const page = await screen.findByTestId('invite-page');
|
||||||
|
expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество');
|
||||||
|
expect(screen.getByText('Обсуждаем всё подряд')).toBeVisible();
|
||||||
|
expect(screen.getByText('Участников: 12')).toBeVisible();
|
||||||
|
expect(screen.getByTestId('invite-inviter')).toHaveTextContent('Боб');
|
||||||
|
// Splash сервера становится фоном страницы.
|
||||||
|
const background = screen.getByTestId('invite-background').parentElement;
|
||||||
|
expect(background?.querySelector('img')?.getAttribute('src')).toBe('/files/splash-9');
|
||||||
|
expect(page).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('переопределение приглашения важнее splash сервера', async () => {
|
||||||
|
installFetch(routes({ background: 'invite-bg' }));
|
||||||
|
renderApp('/invite/abc123');
|
||||||
|
|
||||||
|
await screen.findByTestId('invite-page');
|
||||||
|
const background = await screen.findByTestId('invite-background');
|
||||||
|
expect(background.parentElement?.querySelector('img')?.getAttribute('src')).toBe(
|
||||||
|
'/files/invite-bg',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('присоединяет по кнопке и открывает сервер', async () => {
|
||||||
|
const fetchMock = installFetch(routes());
|
||||||
|
const { router } = renderApp('/invite/abc123');
|
||||||
|
|
||||||
|
await userEvent.click(await screen.findByTestId('invite-accept'));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(findRequest(fetchMock, { url: '/invites/abc123', method: 'POST' })).toBeDefined();
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(router.state.location.pathname).toBe('/app/g-9');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('для участника показывает «Открыть сервер» вместо присоединения', async () => {
|
||||||
|
installFetch(routes({ guild: guildPreview({ is_member: true }) }));
|
||||||
|
renderApp('/invite/abc123');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-open')).toBeVisible();
|
||||||
|
expect(screen.queryByTestId('invite-accept')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('недействительное приглашение объясняет ошибку', async () => {
|
||||||
|
installFailingFetch();
|
||||||
|
renderApp('/invite/broken');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-page')).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('Приглашение не работает')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('страница приглашения для гостя', () => {
|
||||||
|
it('показывает карточку и ведёт на вход с возвратом', async () => {
|
||||||
|
installFetch([
|
||||||
|
{
|
||||||
|
match: '/api/v1/invites/abc123',
|
||||||
|
response: () =>
|
||||||
|
json({
|
||||||
|
invite: { code: 'abc123', guild_id: 'g-9' },
|
||||||
|
guild: guildPreview({ is_member: false }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: '/api/v1/users/@me',
|
||||||
|
response: () => json({ error: { code: 'auth.session_expired', message: 'no' } }, 401),
|
||||||
|
},
|
||||||
|
{ match: '/api/v1/instance', response: () => json({ instance: { name: 'glchat-test' } }) },
|
||||||
|
]);
|
||||||
|
renderApp('/invite/abc123');
|
||||||
|
|
||||||
|
expect(await screen.findByTestId('invite-guild-name')).toHaveTextContent('Сообщество');
|
||||||
|
await userEvent.click(screen.getByTestId('invite-login'));
|
||||||
|
expect(await screen.findByLabelText('Почта', { exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user