feat(invites): страница приглашения со splash и переопределением

- миграция 00015: переопределение splash у приглашения (AGENT.md 7.9)
- GET /invites/{code} доступен без сессии: гость по ссылке видит карточку
  сервера, описание, число участников и пригласившего
- POST/DELETE /invites/{code}/background: своя картинка страницы под правом
  создателя или MANAGE_GUILD, файл удаляется при замене и снятии
- клиент: маршрут /invite/:code с фоном (переопределение → splash → баннер),
  карточкой сервера, кнопками «Присоединиться»/«Открыть сервер» и входом с
  возвратом на страницу (?next=)
- тесты: Go (карточка, загрузка, права, снятие) и Vitest (splash, гость,
  присоединение, недействительная ссылка)
This commit is contained in:
2026-09-21 19:25:06 +03:00
parent ed98c78c9e
commit 566e1cf981
14 changed files with 701 additions and 24 deletions
@@ -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;
+2 -1
View File
@@ -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
View File
@@ -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
}
+95
View File
@@ -602,3 +602,98 @@ func TestBlockUserStopsDMsAndRequests(t *testing.T) {
} }
_ = ownerID _ = 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())
}
}
+1
View File
@@ -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)
+25 -7
View File
@@ -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
+29
View File
@@ -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;
} }
+11 -1
View File
@@ -434,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}}",
+11 -1
View File
@@ -434,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}}",
+205
View File
@@ -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>
);
}
+7 -2
View File
@@ -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: () => {
+1
View File
@@ -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'));
+3
View File
@@ -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 /> }],
+153
View File
@@ -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();
});
});