{t('auth.login.title')}
+{t('auth.login.subtitle')}
++ {t('auth.login.noAccount')}{' '} + {registrationEnabled ? ( + + {t('auth.login.registerLink')} + + ) : ( + {t('auth.login.registrationDisabled')} + )} +
+{t('auth.login.forgot')}
+diff --git a/cmd/glchat/main.go b/cmd/glchat/main.go index ba02e75..d70a163 100644 --- a/cmd/glchat/main.go +++ b/cmd/glchat/main.go @@ -17,7 +17,9 @@ import ( "glchat/internal/config" "glchat/internal/database" "glchat/internal/gateway" + "glchat/internal/permissions" "glchat/internal/server" + "glchat/internal/source" "glchat/internal/store" ) @@ -146,8 +148,12 @@ func run() error { return fmt.Errorf("initialize authentication: %w", err) } - gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins()) - srv := server.New(cfg, db, logger, server.Deps{Store: st, Auth: authService, Gateway: gatewayService}) + // Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш. + calculator := permissions.NewCalculator(source.New(st)) + gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins()) + srv := server.New(cfg, db, logger, server.Deps{ + Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, + }) errCh := make(chan error, 1) go func() { logger.Info("http server listening", diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 8b69ae4..5965892 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -171,12 +171,20 @@ func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Re return user, nil } -// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и -// выдачу роли «Администратор» (AGENT.md 6.3). -func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error { - roles, err := r.store.ListGuildRoles(ctx, guild.ID) +// SeedResult — роли, созданные при настройке сервера. +type SeedResult struct { + AdminRoleID uint64 + MemberRoleID uint64 +} + +// SeedGuildDefaults создаёт роли по умолчанию, добавляет владельца в сервер и +// выдаёт ему роль «Администратор» (AGENT.md 6.3). Функция идемпотентна: её +// используют и установщик, и ручка создания сервера. +func SeedGuildDefaults(ctx context.Context, st *store.Store, guild *store.Guild, owner *store.User) (*SeedResult, error) { + result := &SeedResult{} + roles, err := st.ListGuildRoles(ctx, guild.ID) if err != nil { - return err + return nil, err } var adminRole, defaultRole *store.Role for i := range roles { @@ -188,35 +196,47 @@ func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, o } } if adminRole == nil { - created, err := r.store.CreateRole(ctx, store.CreateRoleParams{ + created, err := st.CreateRole(ctx, store.CreateRoleParams{ GuildID: guild.ID, Name: "Администратор", Permissions: uint64(permissions.AllPermissions), Position: 100, Mentionable: true, }) if err != nil { - return fmt.Errorf("create administrator role: %w", err) + return nil, fmt.Errorf("create administrator role: %w", err) } adminRole = created } result.AdminRoleID = adminRole.ID if defaultRole == nil { - created, err := r.store.CreateRole(ctx, store.CreateRoleParams{ + created, err := st.CreateRole(ctx, store.CreateRoleParams{ GuildID: guild.ID, Name: "Пользователь", Permissions: uint64(permissions.DefaultUserPermissions), Position: 0, IsDefault: true, Mentionable: true, }) if err != nil { - return fmt.Errorf("create default role: %w", err) + return nil, fmt.Errorf("create default role: %w", err) } defaultRole = created } result.MemberRoleID = defaultRole.ID - if _, err := r.store.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil { - return fmt.Errorf("add owner to main guild: %w", err) + if _, err := st.AddGuildMember(ctx, guild.ID, owner.ID, ""); err != nil { + return nil, fmt.Errorf("add owner to guild: %w", err) } - if err := r.store.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil { - return fmt.Errorf("assign administrator role: %w", err) + if err := st.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil { + return nil, fmt.Errorf("assign administrator role: %w", err) } + return result, nil +} + +// ensureMainGuildSetup гарантирует роли по умолчанию, участие владельца и +// выдачу роли «Администратор» (AGENT.md 6.3). +func (r *Runner) ensureMainGuildSetup(ctx context.Context, guild *store.Guild, owner *store.User, result *Result) error { + seeded, err := SeedGuildDefaults(ctx, r.store, guild, owner) + if err != nil { + return err + } + result.AdminRoleID = seeded.AdminRoleID + result.MemberRoleID = seeded.MemberRoleID return nil } diff --git a/internal/database/migrations/00003_phase1_onboarding.sql b/internal/database/migrations/00003_phase1_onboarding.sql new file mode 100644 index 0000000..9e327d4 --- /dev/null +++ b/internal/database/migrations/00003_phase1_onboarding.sql @@ -0,0 +1,6 @@ +-- +goose Up +-- Фаза 1: отметка о завершении первичной настройки пользователя (AGENT.md 7.2). +ALTER TABLE users ADD COLUMN onboarding_completed_at TEXT; + +-- +goose Down +ALTER TABLE users DROP COLUMN onboarding_completed_at; diff --git a/internal/gateway/gateway_test.go b/internal/gateway/gateway_test.go index 2a53ba3..7569ab0 100644 --- a/internal/gateway/gateway_test.go +++ b/internal/gateway/gateway_test.go @@ -60,7 +60,7 @@ func newFixture(t *testing.T) *fixture { t.Fatalf("bootstrap: %v", err) } - service := gateway.New(st, authService, gateway.NewSnapshot(st), logger, []string{"https://gl.test"}) + service := gateway.New(st, authService, gateway.NewSnapshot(st, nil), logger, []string{"https://gl.test"}) server := httptest.NewServer(service.Handler()) t.Cleanup(server.Close) @@ -242,6 +242,62 @@ func TestIdentifyWithInvalidToken(t *testing.T) { } } +// TestIdentifyWithSessionCookie проверяет браузерный вход: токен в IDENTIFY +// не передаётся, сессия берётся из cookie рукопожатия (AGENT.md 8.1). +func TestIdentifyWithSessionCookie(t *testing.T) { + f := newFixture(t) + _, token, session, err := f.auth.Register(context.Background(), auth.RegisterInput{ + Username: "cookie_user", Email: "cookie@example.com", Password: "correct-horse-battery", + }) + if err != nil { + t.Fatalf("register: %v", err) + } + if session == nil { + t.Fatal("register returned no session") + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + url := "ws" + strings.TrimPrefix(f.server.URL, "http") + conn, _, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + HTTPHeader: map[string][]string{ + "Origin": {"https://gl.test"}, + "Cookie": {gateway.SessionCookieName + "=" + token}, + }, + }) + if err != nil { + t.Fatalf("dial with cookie: %v", err) + } + t.Cleanup(func() { _ = conn.CloseNow() }) + + if hello := readEnvelope(t, conn); hello.Op != gateway.OpHello { + t.Fatalf("first frame op = %d, want HELLO", hello.Op) + } + // IDENTIFY без токена: сервер доверяет cookie. + send(t, conn, gateway.OpIdentify, map[string]any{}) + ready := readEnvelope(t, conn) + if ready.T != "READY" { + t.Fatalf("frame = %q, want READY", ready.T) + } + var snapshot gateway.Ready + if err := json.Unmarshal(ready.D, &snapshot); err != nil { + t.Fatalf("decode READY: %v", err) + } + if snapshot.User.Username != "cookie_user" { + t.Fatalf("READY user = %q, want cookie_user", snapshot.User.Username) + } +} + +func TestIdentifyWithoutTokenOrCookie(t *testing.T) { + f := newFixture(t) + conn, _ := f.dial(t) + send(t, conn, gateway.OpIdentify, map[string]any{}) + response := readEnvelope(t, conn) + if response.Op != gateway.OpInvalidSess { + t.Fatalf("op = %d, want INVALID_SESSION", response.Op) + } +} + func TestHeartbeatAck(t *testing.T) { f := newFixture(t) token := registerUser(t, f, "heartbeat_user", "heartbeat@example.com") diff --git a/internal/gateway/ready.go b/internal/gateway/ready.go index cf8a849..e756fbb 100644 --- a/internal/gateway/ready.go +++ b/internal/gateway/ready.go @@ -2,6 +2,7 @@ package gateway import ( "context" + "errors" "fmt" "strconv" "time" @@ -17,8 +18,13 @@ type Snapshot struct { calculator *permissions.Calculator } -func NewSnapshot(st *store.Store) *Snapshot { - return &Snapshot{store: st, calculator: permissions.NewCalculator(newPermissionSource(st))} +// NewSnapshot собирает READY через переданный калькулятор прав: сервер и +// Gateway должны использовать один кэш, иначе инвалидация не видна обоим. +func NewSnapshot(st *store.Store, calculator *permissions.Calculator) *Snapshot { + if calculator == nil { + calculator = permissions.NewCalculator(newPermissionSource(st)) + } + return &Snapshot{store: st, calculator: calculator} } var _ SnapshotBuilder = (*Snapshot)(nil) @@ -182,6 +188,16 @@ func (p *permissionSource) MemberRoleIDs(ctx context.Context, guildID, userID ui return p.store.MemberRoleIDs(ctx, guildID, userID) } +func (p *permissionSource) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) { + if _, err := p.store.GetGuildMember(ctx, guildID, userID); err != nil { + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + func (p *permissionSource) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) { overrides, err := p.store.ListChannelOverrides(ctx, channelID) if err != nil { diff --git a/internal/gateway/session.go b/internal/gateway/session.go index a46fa23..d7fdd39 100644 --- a/internal/gateway/session.go +++ b/internal/gateway/session.go @@ -12,6 +12,8 @@ import ( "time" "github.com/coder/websocket" + + "glchat/internal/store" ) // clientSession — одно подключение клиента (AGENT.md 8.3: сессий может быть @@ -24,8 +26,16 @@ type clientSession struct { closeOnce sync.Once closed chan struct{} buffer *resumeBuffer + // preAuth — сессия, восстановленная из cookie на рукопожатии: браузерный + // клиент не имеет доступа к токену (AGENT.md 8.1, 8.3). + preAuth *store.Session } +// SessionCookieName — имя cookie сессии. Значение должно совпадать с +// server.sessionCookieName: браузерный Gateway-клиент аутентифицируется +// cookie, а не токеном в IDENTIFY (AGENT.md 8.1). +const SessionCookieName = "__Host-session" + // outbound — кадр в очереди на отправку. done используется, когда отправитель // ждёт фактической записи в сокет (например, перед закрытием соединения). type outbound struct { @@ -97,6 +107,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { send: make(chan outbound, 128), closed: make(chan struct{}), } + // Cookie сессии проверяем до HELLO: ошибку вернёт IDENTIFY. + if cookie, err := r.Cookie(SessionCookieName); err == nil && cookie.Value != "" { + if _, authSession, err := s.auth.ResolveSession(ctx, cookie.Value); err == nil { + session.preAuth = authSession + } + } conn.SetReadLimit(MaxIncomingFrame) // HELLO с интервалом heartbeat и идентификатором сессии. @@ -215,7 +231,21 @@ func (s *Service) sendInvalidSession(session *clientSession, reason string) { // handleIdentify проверяет токен, регистрирует сессию и отправляет READY // либо догоняет пропущенные события при RESUME. func (s *Service) handleIdentify(ctx context.Context, session *clientSession, payload identifyPayload, resume bool) error { - user, authSession, err := s.auth.ResolveSession(ctx, payload.Token) + var ( + user *store.User + authSession *store.Session + err error + ) + switch { + case payload.Token != "": + user, authSession, err = s.auth.ResolveSession(ctx, payload.Token) + case session.preAuth != nil: + // Токен не передан: доверяем cookie, проверенной на рукопожатии. + user, err = s.store.GetUser(ctx, session.preAuth.UserID) + authSession = session.preAuth + default: + err = errors.New("identify without token and without session cookie") + } if err != nil { s.sendInvalidSession(session, "invalid token") return err @@ -340,14 +370,12 @@ func writeDirect(ctx context.Context, conn *websocket.Conn, payload []byte) erro func decodeIdentify(raw json.RawMessage) (identifyPayload, error) { var payload identifyPayload if len(raw) == 0 { - return payload, errors.New("empty identify payload") + // Пустой payload допустим: браузер аутентифицируется cookie. + return payload, nil } if err := json.Unmarshal(raw, &payload); err != nil { return payload, err } - if payload.Token == "" { - return payload, errors.New("token is required") - } return payload, nil } diff --git a/internal/permissions/channel.go b/internal/permissions/channel.go index 8978df4..e97795d 100644 --- a/internal/permissions/channel.go +++ b/internal/permissions/channel.go @@ -18,6 +18,11 @@ func (c *Calculator) Channel(ctx context.Context, guildID, channelID, userID uin return Resolved{}, err } resolved.Channel = resolved.Guild + if !resolved.IsMember && !resolved.IsInstanceAdmin { + // Не участник не видит ничего, даже если есть персональный оверрайд. + c.cache.PutChannel(channelID, userID, resolved) + return resolved, nil + } if resolved.IsInstanceAdmin || resolved.Guild.Has(Administrator) { c.cache.PutChannel(channelID, userID, resolved) return resolved, nil diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go index fe374b6..9981d9f 100644 --- a/internal/permissions/permissions.go +++ b/internal/permissions/permissions.go @@ -158,6 +158,9 @@ type Resolved struct { IsOwner bool // IsInstanceAdmin — глобальная роль «Администратор сервера» (AGENT.md 7.19). IsInstanceAdmin bool + // IsMember — состоит ли пользователь в сервере. Не участник не получает + // прав роли @user: сервер для него невидим (AGENT.md 6.2). + IsMember bool // TimedOut — участник в тайм-ауте: отправка сообщений запрещена (AGENT.md 7.10). TimedOut bool } @@ -167,6 +170,9 @@ func (r Resolved) Has(permission Permission) bool { if r.IsInstanceAdmin { return true } + if !r.IsMember { + return false + } if r.Guild.Has(Administrator) { return true } @@ -178,6 +184,9 @@ func (r Resolved) Can(permission Permission) bool { if r.IsInstanceAdmin { return true } + if !r.IsMember { + return false + } if r.Channel.Has(Administrator) { return true } @@ -197,16 +206,48 @@ func (r Resolved) CanViewChannel() bool { if r.IsInstanceAdmin { return true } + if !r.IsMember { + return false + } if r.Channel.Has(Administrator) { return true } return r.Channel.Has(ViewChannel) } +// Names возвращает список названий установленных прав: клиенту удобнее +// работать со списком, чем с битовой маской (AGENT.md 8.2). +func Names(permission Permission) []string { + names := make([]string, 0, 8) + for bit := Permission(1); bit != 0 && bit <= Administrator; bit <<= 1 { + if permission&bit != 0 { + names = append(names, permissionNames[bit]) + } + } + return names +} + +// Parse разбирает строку с названиями прав: "VIEW_CHANNEL|SEND_MESSAGES", +// "VIEW_CHANNEL,SEND_MESSAGES" или через пробел. Пустая строка — нет прав. +func Parse(value string) (Permission, error) { + normalized := strings.NewReplacer(",", " ", "|", " ").Replace(value) + var result Permission + for _, name := range strings.Fields(normalized) { + permission, ok := ByName(name) + if !ok { + return 0, fmt.Errorf("unknown permission %q", name) + } + result |= permission + } + return result, nil +} + // Source — данные для расчёта прав: роли сервера и роли участника. type Source interface { GuildRoles(ctx context.Context, guildID uint64) ([]RoleData, error) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([]uint64, error) + // IsMember отвечает, состоит ли пользователь в сервере. + IsMember(ctx context.Context, guildID, userID uint64) (bool, error) ChannelOverrides(ctx context.Context, channelID uint64) ([]OverrideData, error) GuildOwnerID(ctx context.Context, guildID uint64) (uint64, error) MemberTimeout(ctx context.Context, guildID, userID uint64) (bool, error) @@ -268,6 +309,19 @@ func (c *Calculator) Guild(ctx context.Context, guildID, userID uint64, instance } resolved := Resolved{IsOwner: ownerID == userID, IsInstanceAdmin: instanceAdmin} + resolved.IsMember = resolved.IsOwner + if !resolved.IsMember { + resolved.IsMember, err = c.source.IsMember(ctx, guildID, userID) + if err != nil { + return Resolved{}, fmt.Errorf("load guild membership: %w", err) + } + } + if !resolved.IsMember { + // Не участник: прав нет вовсе, роль @user на него не действует. + c.cache.PutGuild(guildID, userID, resolved) + return resolved, nil + } + assigned := make(map[uint64]bool, len(memberRoleIDs)) for _, roleID := range memberRoleIDs { assigned[roleID] = true diff --git a/internal/permissions/permissions_test.go b/internal/permissions/permissions_test.go index 295db75..f70be68 100644 --- a/internal/permissions/permissions_test.go +++ b/internal/permissions/permissions_test.go @@ -36,6 +36,15 @@ func (f *fakeSource) MemberRoleIDs(_ context.Context, guildID, userID uint64) ([ return nil, nil } +func (f *fakeSource) IsMember(_ context.Context, guildID, userID uint64) (bool, error) { + if byUser, ok := f.memberRoles[guildID]; ok { + if _, found := byUser[userID]; found { + return true, nil + } + } + return false, nil +} + func (f *fakeSource) ChannelOverrides(_ context.Context, channelID uint64) ([]OverrideData, error) { return f.overrides[channelID], nil } @@ -92,6 +101,39 @@ func TestDefaultRoleProvidesBasePermissions(t *testing.T) { } } +func TestNonMemberHasNoPermissions(t *testing.T) { + source := newFakeSource() + source.roles[guildID] = []RoleData{ + {ID: defaultID, Permissions: DefaultUserPermissions, IsDefault: true, Position: 0}, + } + source.owners[guildID] = ownerID + source.overrides[channelID] = []OverrideData{ + {TargetType: "user", TargetID: memberID, Allow: ViewChannel | SendMessages}, + } + calculator := NewCalculator(source) + + // Пользователь вне сервера: роль @user и персональный оверрайд не действуют. + resolved, err := calculator.Channel(context.Background(), guildID, channelID, memberID, false) + if err != nil { + t.Fatalf("Channel: %v", err) + } + if resolved.IsMember { + t.Fatal("user without membership must not be a member") + } + if resolved.Has(ViewGuild) || resolved.CanViewChannel() || resolved.Can(SendMessages) { + t.Fatalf("non-member received permissions: guild=%s channel=%s", resolved.Guild, resolved.Channel) + } + + // Инстанс-админ обходит отсутствие участия (AGENT.md 7.19). + admin, err := calculator.Channel(context.Background(), guildID, channelID, memberID, true) + if err != nil { + t.Fatalf("Channel as instance admin: %v", err) + } + if !admin.CanViewChannel() || !admin.Has(ManageGuild) { + t.Fatal("instance admin must bypass membership checks") + } +} + func TestAssignedRoleAddsPermissions(t *testing.T) { source, calculator := fixture() source.memberRoles[guildID][memberID] = []uint64{moderatorRole} diff --git a/internal/server/api_context.go b/internal/server/api_context.go new file mode 100644 index 0000000..9579208 --- /dev/null +++ b/internal/server/api_context.go @@ -0,0 +1,158 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// sessionContextValue — сессия, восстановленная из cookie или Bearer-токена. +type sessionContextValue struct { + User *store.User + Session *store.Session +} + +type sessionContextKey struct{} + +// sessionContext резолвит сессию один раз на запрос и кладёт её в контекст. +// Отсутствие сессии не ошибка: часть ручек публичная (мета, OpenAPI). +func (s *Server) sessionContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.auth == nil { + next.ServeHTTP(w, r) + return + } + token := sessionToken(r) + if token == "" { + next.ServeHTTP(w, r) + return + } + user, session, err := s.auth.ResolveSession(r.Context(), token) + if err != nil { + next.ServeHTTP(w, r) + return + } + ctx := context.WithValue(r.Context(), sessionContextKey{}, &sessionContextValue{User: user, Session: session}) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// sessionToken читает токен сессии из cookie (браузер) или Bearer (desktop). +func sessionToken(r *http.Request) string { + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + return cookie.Value + } + return normalizeBearer(r.Header.Get("Authorization")) +} + +func sessionFromContext(ctx context.Context) (*store.User, *store.Session, bool) { + value, ok := ctx.Value(sessionContextKey{}).(*sessionContextValue) + if !ok || value.User == nil || value.Session == nil { + return nil, nil, false + } + return value.User, value.Session, true +} + +// requireUser достаёт текущего пользователя или возвращает 401 в конверте API. +func requireUser(ctx context.Context) (*store.User, *store.Session, error) { + user, session, ok := sessionFromContext(ctx) + if !ok { + return nil, nil, humaErrorStatus(http.StatusUnauthorized, "auth.session_expired", "authentication required") + } + return user, session, nil +} + +// requireInstanceAdmin проверяет права администратора инстанса (AGENT.md 6.5). +func requireInstanceAdmin(ctx context.Context) (*store.User, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + if !user.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") + } + return user, nil +} + +// humaAPIError — ошибка huma в едином конверте {"error":{"code","message"}} +// (AGENT.md 8.5): клиент разбирает код, а не текст. +type humaAPIError struct { + status int + code string + message string +} + +func (e *humaAPIError) Error() string { return e.code + ": " + e.message } +func (e *humaAPIError) GetStatus() int { return e.status } + +func (e *humaAPIError) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "error": map[string]any{"code": e.code, "message": e.message}, + }) +} + +// humaErrorStatus создаёт ошибку с явным кодом. +func humaErrorStatus(status int, code, message string) huma.StatusError { + return &humaAPIError{status: status, code: code, message: message} +} + +// humaError переводит доменную ошибку в ответ API с тем же кодом, что и +// chi-ручки (единая таблица в errors.go). +func humaError(err error) huma.StatusError { + if err == nil { + return nil + } + converted := newAPIError(err) + return &humaAPIError{status: converted.Status, code: converted.Code, message: converted.Message} +} + +// parseID разбирает Snowflake из пути: идентификаторы в API — строки (AGENT.md 6.4). +func parseID(name, value string) (uint64, error) { + if value == "" { + return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" is required") + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0, humaErrorStatus(http.StatusBadRequest, "request.bad", name+" must be a snowflake id") + } + return parsed, nil +} + +// guildPermissions считает права пользователя на сервере. +func (s *Server) guildPermissions(ctx context.Context, guildID uint64, user *store.User) (permissions.Resolved, error) { + resolved, err := s.perms.Guild(ctx, guildID, user.ID, user.IsInstanceAdmin) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found") + } + return permissions.Resolved{}, humaError(err) + } + if !resolved.Has(permissions.ViewGuild) { + // Существование сервера не подтверждаем: 404 вместо 403 (AGENT.md 9.7). + return permissions.Resolved{}, humaErrorStatus(http.StatusNotFound, "not_found", "guild not found") + } + return resolved, nil +} + +// requireGuildPermission считает права и требует конкретное разрешение. +func (s *Server) requireGuildPermission(ctx context.Context, guildID string, user *store.User, permission permissions.Permission) (uint64, permissions.Resolved, error) { + id, err := parseID("guild_id", guildID) + if err != nil { + return 0, permissions.Resolved{}, err + } + resolved, err := s.guildPermissions(ctx, id, user) + if err != nil { + return 0, permissions.Resolved{}, err + } + if !resolved.Has(permission) { + return 0, permissions.Resolved{}, humaErrorStatus(http.StatusForbidden, "perm.denied", "permission denied") + } + return id, resolved, nil +} diff --git a/internal/server/api_guilds.go b/internal/server/api_guilds.go new file mode 100644 index 0000000..604fada --- /dev/null +++ b/internal/server/api_guilds.go @@ -0,0 +1,1392 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/bootstrap" + "glchat/internal/permissions" + "glchat/internal/store" +) + +type channelPayload struct { + ID string `json:"id"` + GuildID string `json:"guild_id,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Position int `json:"position"` + ParentID string `json:"parent_id,omitempty"` + SlowmodeSeconds int `json:"slowmode_seconds"` + UserLimit int `json:"user_limit"` + CanView bool `json:"can_view"` + CanSend bool `json:"can_send"` + CanConnect bool `json:"can_connect"` +} + +type rolePayload struct { + ID string `json:"id"` + Name string `json:"name"` + Color int64 `json:"color"` + Position int `json:"position"` + Permissions string `json:"permissions"` + IsDefault bool `json:"is_default"` + Hoist bool `json:"hoist"` + Mentionable bool `json:"mentionable"` +} + +type memberPayload struct { + UserID string `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Nickname string `json:"nickname,omitempty"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + Status string `json:"status"` + CustomStatus string `json:"custom_status"` + IsInstanceAdmin bool `json:"is_instance_admin"` + JoinedAt string `json:"joined_at"` + TimeoutUntil string `json:"timeout_until,omitempty"` + RoleIDs []string `json:"role_ids"` +} + +type guildDetailPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID string `json:"owner_id"` + IconFileID string `json:"icon_file_id,omitempty"` + IsMain bool `json:"is_main"` + MemberCount int `json:"member_count"` + Roles []rolePayload `json:"roles"` + MyRoleIDs []string `json:"my_role_ids"` + MyPermissions []string `json:"my_permissions"` + Channels []channelPayload `json:"channels,omitempty"` +} + +func channelFromStore(channel store.Channel, resolved *permissions.Resolved) channelPayload { + payload := channelPayload{ + ID: formatSnowflake(channel.ID), + Type: string(channel.Type), + Name: channel.Name, + Description: channel.Description, + Position: channel.Position, + SlowmodeSeconds: channel.SlowmodeSeconds, + UserLimit: channel.UserLimit, + } + if channel.GuildID != nil { + payload.GuildID = formatSnowflake(*channel.GuildID) + } + if channel.ParentID != nil { + payload.ParentID = formatSnowflake(*channel.ParentID) + } + if resolved != nil { + payload.CanView = resolved.CanViewChannel() + payload.CanSend = resolved.Can(permissions.SendMessages) + payload.CanConnect = resolved.Can(permissions.ConnectVoice) + } + return payload +} + +func roleFromStore(role store.Role) rolePayload { + return rolePayload{ + ID: formatSnowflake(role.ID), + Name: role.Name, + Color: role.Color, + Position: role.Position, + Permissions: permissions.Permission(role.Permissions).String(), + IsDefault: role.IsDefault, + Hoist: role.Hoist, + Mentionable: role.Mentionable, + } +} + +type guildOutput struct { + Body struct { + Guild guildDetailPayload `json:"guild"` + } +} + +type channelOutput struct { + Body struct { + Channel channelPayload `json:"channel"` + } +} + +type channelListOutput struct { + Body struct { + Channels []channelPayload `json:"channels"` + } +} + +type memberListOutput struct { + Body struct { + Members []memberPayload `json:"members"` + } +} + +type roleOutput struct { + Body struct { + Role rolePayload `json:"role"` + } +} + +type roleListOutput struct { + Body struct { + Roles []rolePayload `json:"roles"` + } +} + +// registerGuildRoutes описывает ручки серверов, комнат, участников и ролей. +func (s *Server) registerGuildRoutes(api huma.API) { + s.registerGuildCoreRoutes(api) + s.registerChannelRoutes(api) + s.registerMemberRoutes(api) + s.registerRoleRoutes(api) +} + +func (s *Server) registerGuildCoreRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "createGuild", + Method: http.MethodPost, + Path: "/guilds", + Summary: "Создать сервер", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name" minLength:"1" maxLength:"64"` + } + }, + ) (*guildOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + name := strings.TrimSpace(input.Body.Name) + if name == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") + } + guild, err := s.createGuild(ctx, user, name) + if err != nil { + return nil, err + } + return s.guildOutput(ctx, user, *guild) + }) + + huma.Register(api, huma.Operation{ + OperationID: "getGuild", + Method: http.MethodGet, + Path: "/guilds/{guild_id}", + Summary: "Сервер: роли и права", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*guildOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + return s.guildOutput(ctx, user, *guild) + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateGuild", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}", + Summary: "Изменить сервер", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Body struct { + Name *string `json:"name,omitempty" maxLength:"64"` + Description *string `json:"description,omitempty" maxLength:"500"` + } + }, + ) (*guildOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageGuild) + if err != nil { + return nil, err + } + params := store.UpdateGuildParams{Description: input.Body.Description} + if input.Body.Name != nil { + trimmed := strings.TrimSpace(*input.Body.Name) + if trimmed == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") + } + params.Name = &trimmed + } + guild, err := s.store.UpdateGuild(ctx, guildID, params) + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + if s.gateway != nil { + s.gateway.Dispatch("GUILD_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "name": guild.Name, + }) + } + return s.guildOutput(ctx, user, *guild) + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteGuild", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}", + Summary: "Удалить сервер (владелец или админ инстанса)", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + if !resolved.IsOwner && !user.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "only the owner can delete the guild") + } + if err := s.deleteGuild(ctx, user, guildID); err != nil { + return nil, err + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "joinGuild", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/join", + Summary: "Присоединиться к открытому серверу", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, err := parseID("guild_id", input.GuildID) + if err != nil { + return nil, err + } + if err := s.joinGuild(ctx, user, guildID); err != nil { + return nil, err + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "leaveGuild", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/leave", + Summary: "Покинуть сервер", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + if resolved.IsOwner { + return nil, humaErrorStatus(http.StatusForbidden, "guild.owner_cannot_leave", "owner cannot leave the guild") + } + if err := s.store.RemoveGuildMember(ctx, guildID, user.ID); err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + if s.gateway != nil { + s.gateway.SendToUser(user.ID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)}) + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listAuditLog", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/audit-log", + Summary: "Журнал действий сервера", + Tags: []string{"Guilds"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + }, + ) (*auditListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewAuditLog) + if err != nil { + return nil, err + } + entries, err := s.store.ListAuditLog(ctx, guildID, input.Limit) + if err != nil { + return nil, humaError(err) + } + output := &auditListOutput{} + output.Body.Entries = auditPayloads(entries) + return output, nil + }) +} + +func (s *Server) registerChannelRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildChannels", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/channels", + Summary: "Комнаты сервера, видимые пользователю", + Tags: []string{"Channels"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*channelListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + channels, err := s.visibleChannels(ctx, guildID, user) + if err != nil { + return nil, err + } + output := &channelListOutput{} + output.Body.Channels = channels + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "createChannel", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/channels", + Summary: "Создать комнату", + Tags: []string{"Channels"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Body struct { + Name string `json:"name" minLength:"1" maxLength:"64"` + Type string `json:"type,omitempty" enum:"text,voice,category"` + ParentID string `json:"parent_id,omitempty"` + SlowmodeSeconds int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"` + UserLimit int `json:"user_limit,omitempty" minimum:"0" maximum:"99"` + } + }, + ) (*channelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels) + if err != nil { + return nil, err + } + channelType := store.ChannelType(input.Body.Type) + if channelType == "" { + channelType = store.ChannelText + } + switch channelType { + // Личные комнаты (dm) создаются не через API сервера (AGENT.md 6.3). + case store.ChannelText, store.ChannelVoice, store.ChannelCategory: + case store.ChannelDM: + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "dm channels are created by the messaging API") + default: + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "unknown channel type") + } + name := strings.TrimSpace(input.Body.Name) + if name == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "channel name must not be empty") + } + params := store.CreateChannelParams{ + GuildID: &guildID, + Type: channelType, + Name: name, + SlowmodeSeconds: input.Body.SlowmodeSeconds, + UserLimit: input.Body.UserLimit, + } + if input.Body.ParentID != "" { + parentID, err := parseID("parent_id", input.Body.ParentID) + if err != nil { + return nil, err + } + params.ParentID = &parentID + } + channel, err := s.store.CreateChannel(ctx, params) + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.dispatchGuildEvent(guildID, "CHANNEL_CREATE", channelFromStore(*channel, nil)) + // Права на новую комнату считаем для автора запроса. + resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin) + if err != nil { + return nil, humaError(err) + } + output := &channelOutput{} + output.Body.Channel = channelFromStore(*channel, &resolved) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateChannel", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/channels/{channel_id}", + Summary: "Изменить комнату", + Tags: []string{"Channels"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + ChannelID string `path:"channel_id"` + Body struct { + Name *string `json:"name,omitempty" maxLength:"64"` + Description *string `json:"description,omitempty" maxLength:"500"` + Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"` + SlowmodeSeconds *int `json:"slowmode_seconds,omitempty" minimum:"0" maximum:"21600"` + UserLimit *int `json:"user_limit,omitempty" minimum:"0" maximum:"99"` + } + }, + ) (*channelOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels) + if err != nil { + return nil, err + } + channelID, err := parseID("channel_id", input.ChannelID) + if err != nil { + return nil, err + } + if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil { + return nil, err + } + params := store.UpdateChannelParams{ + Name: input.Body.Name, + Description: input.Body.Description, + Position: input.Body.Position, + SlowmodeSeconds: input.Body.SlowmodeSeconds, + UserLimit: input.Body.UserLimit, + } + channel, err := s.store.UpdateChannel(ctx, channelID, params) + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.dispatchGuildEvent(guildID, "CHANNEL_UPDATE", channelFromStore(*channel, nil)) + resolved, err := s.perms.Channel(ctx, guildID, channelID, user.ID, user.IsInstanceAdmin) + if err != nil { + return nil, humaError(err) + } + output := &channelOutput{} + output.Body.Channel = channelFromStore(*channel, &resolved) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteChannel", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/channels/{channel_id}", + Summary: "Удалить комнату", + Tags: []string{"Channels"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + ChannelID string `path:"channel_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageChannels) + if err != nil { + return nil, err + } + channelID, err := parseID("channel_id", input.ChannelID) + if err != nil { + return nil, err + } + if err := s.requireChannelInGuild(ctx, guildID, channelID); err != nil { + return nil, err + } + if err := s.store.DeleteChannel(ctx, channelID); err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.dispatchGuildEvent(guildID, "CHANNEL_DELETE", map[string]any{ + "channel_id": formatSnowflake(channelID), + "guild_id": formatSnowflake(guildID), + }) + return newOKOutput(), nil + }) +} + +// requireChannelInGuild проверяет, что комната принадлежит серверу (AGENT.md 9.7). +func (s *Server) requireChannelInGuild(ctx context.Context, guildID, channelID uint64) error { + channel, err := s.store.GetChannel(ctx, channelID) + if err != nil { + return humaError(err) + } + if channel.GuildID == nil || *channel.GuildID != guildID { + return humaErrorStatus(http.StatusNotFound, "not_found", "channel not found") + } + return nil +} + +// visibleChannels возвращает комнаты сервера, которые пользователь видит. +func (s *Server) visibleChannels(ctx context.Context, guildID uint64, user *store.User) ([]channelPayload, error) { + channels, err := s.store.ListGuildChannels(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + visible := make([]channelPayload, 0, len(channels)) + for _, channel := range channels { + resolved, err := s.perms.Channel(ctx, guildID, channel.ID, user.ID, user.IsInstanceAdmin) + if err != nil { + return nil, humaError(err) + } + if !resolved.CanViewChannel() { + continue + } + visible = append(visible, channelFromStore(channel, &resolved)) + } + return visible, nil +} + +// guildOutput собирает подробную карточку сервера с ролями и комнатами. +func (s *Server) guildOutput(ctx context.Context, user *store.User, guild store.Guild) (*guildOutput, error) { + resolved, err := s.guildPermissions(ctx, guild.ID, user) + if err != nil { + return nil, err + } + payload := guildDetailPayload{ + ID: formatSnowflake(guild.ID), + Name: guild.Name, + Description: guild.Description, + OwnerID: formatSnowflake(guild.OwnerID), + IsMain: guild.IsMain, + MyRoleIDs: []string{}, + MyPermissions: permissions.Names(resolved.Guild), + Roles: []rolePayload{}, + } + if guild.IconFileID != nil { + payload.IconFileID = formatSnowflake(*guild.IconFileID) + } + roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, user.ID) + if err != nil { + return nil, humaError(err) + } + for _, roleID := range roleIDs { + payload.MyRoleIDs = append(payload.MyRoleIDs, formatSnowflake(roleID)) + } + roles, err := s.store.ListGuildRoles(ctx, guild.ID) + if err != nil { + return nil, humaError(err) + } + for _, role := range roles { + payload.Roles = append(payload.Roles, roleFromStore(role)) + } + channels, err := s.visibleChannels(ctx, guild.ID, user) + if err != nil { + return nil, err + } + payload.Channels = channels + count, err := s.store.CountGuildMembers(ctx, guild.ID) + if err != nil { + return nil, humaError(err) + } + payload.MemberCount = count + output := &guildOutput{} + output.Body.Guild = payload + return output, nil +} + +// dispatchGuildEvent рассылает событие всем подключённым клиентам. Клиент +// сам решает, относится ли событие к его серверу: в Фазе 1 подписки на +// серверы не отслеживаются, фильтрация появится вместе с чатом (AGENT.md 8.3). +func (s *Server) dispatchGuildEvent(_ uint64, event string, payload any) { + if s.gateway == nil { + return + } + s.gateway.Dispatch(event, payload) +} + +func (s *Server) invalidateGuild(guildID uint64) { + if s.perms != nil { + s.perms.InvalidateGuild(guildID) + } +} + +func (s *Server) registerMemberRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildMembers", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/members", + Summary: "Участники сервера", + Tags: []string{"Members"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Limit int `query:"limit" default:"100" minimum:"1" maximum:"1000"` + }, + ) (*memberListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + profiles, err := s.store.ListGuildMemberProfiles(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + output := &memberListOutput{} + output.Body.Members = make([]memberPayload, 0, len(profiles)) + for _, profile := range profiles { + if len(output.Body.Members) >= input.Limit { + break + } + output.Body.Members = append(output.Body.Members, memberFromProfile(profile)) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateGuildMember", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/members/{user_id}", + Summary: "Никнейм и тайм-аут участника", + Tags: []string{"Members"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + Body struct { + Nickname *string `json:"nickname,omitempty" maxLength:"32"` + TimeoutUntil *string `json:"timeout_until,omitempty"` + } + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { + return nil, err + } + var ( + nickname *string + timeoutUntil *time.Time + ) + if input.Body.Nickname != nil { + if !resolved.Has(permissions.ManageNicknames) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "MANAGE_NICKNAMES is required") + } + trimmed := strings.TrimSpace(*input.Body.Nickname) + nickname = &trimmed + } + if input.Body.TimeoutUntil != nil { + if !resolved.Has(permissions.TimeoutMembers) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "TIMEOUT_MEMBERS is required") + } + parsed, err := time.Parse(time.RFC3339, *input.Body.TimeoutUntil) + if err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "timeout_until must be RFC3339") + } + timeoutUntil = &parsed + } + if err := s.store.UpdateGuildMember(ctx, guildID, targetID, nickname, timeoutUntil); err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.dispatchGuildEvent(guildID, "MEMBER_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "user_id": formatSnowflake(targetID), + "nickname": nickname, + }) + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "kickGuildMember", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/members/{user_id}", + Summary: "Исключить участника", + Tags: []string{"Members"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.KickMembers) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { + return nil, err + } + if err := s.store.RemoveGuildMember(ctx, guildID, targetID); err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "member.kick", "user", &targetID, "") + s.dispatchGuildEvent(guildID, "MEMBER_REMOVE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "user_id": formatSnowflake(targetID), + }) + if s.gateway != nil { + s.gateway.SendToUser(targetID, "GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)}) + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "addMemberRole", + Method: http.MethodPut, + Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}", + Summary: "Выдать роль участнику", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + RoleID string `path:"role_id"` + }, + ) (*okOutput, error) { + return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, true) + }) + + huma.Register(api, huma.Operation{ + OperationID: "removeMemberRole", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/members/{user_id}/roles/{role_id}", + Summary: "Снять роль с участника", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + UserID string `path:"user_id"` + RoleID string `path:"role_id"` + }, + ) (*okOutput, error) { + return s.changeMemberRole(ctx, input.GuildID, input.UserID, input.RoleID, false) + }) +} + +// changeMemberRole выдаёт или снимает роль с проверкой иерархии (AGENT.md 6.3). +func (s *Server) changeMemberRole(ctx context.Context, guildIDRaw, userIDRaw, roleIDRaw string, assign bool) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, guildIDRaw, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + targetID, err := parseID("user_id", userIDRaw) + if err != nil { + return nil, err + } + roleID, err := parseID("role_id", roleIDRaw) + if err != nil { + return nil, err + } + if err := s.checkMemberHierarchy(ctx, guildID, user, resolved, targetID); err != nil { + return nil, err + } + role, err := s.store.GetRole(ctx, roleID) + if err != nil { + return nil, humaError(err) + } + if role.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found") + } + if !s.outranksRole(ctx, guildID, user, resolved, role) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours") + } + if role.IsDefault { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role is assigned to everyone") + } + if assign { + err = s.store.AssignRole(ctx, guildID, targetID, roleID) + } else { + err = s.store.RemoveRole(ctx, guildID, targetID, roleID) + } + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + action := "member.role_add" + if !assign { + action = "member.role_remove" + } + s.recordAudit(ctx, user, guildID, action, "role", &roleID, "") + s.dispatchGuildEvent(guildID, "MEMBER_ROLES_UPDATE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "user_id": formatSnowflake(targetID), + "role_id": formatSnowflake(roleID), + "assigned": assign, + }) + return newOKOutput(), nil +} + +// checkMemberHierarchy запрещает действовать против владельца и участников с +// более высокими ролями (AGENT.md 6.3, 7.17). +func (s *Server) checkMemberHierarchy(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, targetID uint64) error { + if actor.IsInstanceAdmin { + return nil + } + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return humaError(err) + } + if targetID == guild.OwnerID && actor.ID != guild.OwnerID { + return humaErrorStatus(http.StatusForbidden, "perm.denied", "guild owner cannot be moderated") + } + if targetID == actor.ID { + return humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot moderate yourself") + } + actorRoles, err := s.roleData(ctx, guildID, actor.ID) + if err != nil { + return err + } + targetRoles, err := s.roleData(ctx, guildID, targetID) + if err != nil { + return err + } + targetIsOwner := targetID == guild.OwnerID + if !permissions.OutranksMember(actorRoles, targetRoles, resolved.IsOwner, targetIsOwner) { + return humaErrorStatus(http.StatusForbidden, "perm.denied", "target member has an equal or higher role") + } + return nil +} + +// outranksRole проверяет, что роль ниже роли действующего пользователя. +func (s *Server) outranksRole(ctx context.Context, guildID uint64, actor *store.User, resolved permissions.Resolved, role *store.Role) bool { + if actor.IsInstanceAdmin || resolved.IsOwner { + return true + } + actorRoles, err := s.roleData(ctx, guildID, actor.ID) + if err != nil { + return false + } + return permissions.OutranksRole(actorRoles, permissions.RoleData{ + ID: role.ID, + Permissions: permissions.Permission(role.Permissions), + IsDefault: role.IsDefault, + Position: role.Position, + }, false) +} + +// roleData переводит роли участника в данные движка прав. +func (s *Server) roleData(ctx context.Context, guildID, userID uint64) ([]permissions.RoleData, error) { + roles, err := s.store.MemberRoles(ctx, guildID, userID) + if err != nil { + return nil, humaError(err) + } + result := make([]permissions.RoleData, 0, len(roles)) + for _, role := range roles { + result = append(result, permissions.RoleData{ + ID: role.ID, + Permissions: permissions.Permission(role.Permissions), + IsDefault: role.IsDefault, + Position: role.Position, + }) + } + return result, nil +} + +func (s *Server) registerRoleRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "listGuildRoles", + Method: http.MethodGet, + Path: "/guilds/{guild_id}/roles", + Summary: "Роли сервера", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*roleListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ViewGuild) + if err != nil { + return nil, err + } + roles, err := s.store.ListGuildRoles(ctx, guildID) + if err != nil { + return nil, humaError(err) + } + output := &roleListOutput{} + output.Body.Roles = make([]rolePayload, 0, len(roles)) + for _, role := range roles { + output.Body.Roles = append(output.Body.Roles, roleFromStore(role)) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "createGuildRole", + Method: http.MethodPost, + Path: "/guilds/{guild_id}/roles", + Summary: "Создать роль", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + Body struct { + Name string `json:"name" minLength:"1" maxLength:"64"` + Permissions string `json:"permissions,omitempty"` + Color int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"` + Hoist bool `json:"hoist,omitempty"` + Mentionable bool `json:"mentionable,omitempty"` + } + }, + ) (*roleOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, _, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + permissionSet, err := permissions.Parse(input.Body.Permissions) + if err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error()) + } + if !s.canGrantPermissions(ctx, guildID, user, permissionSet) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have") + } + name := strings.TrimSpace(input.Body.Name) + if name == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "role name must not be empty") + } + role, err := s.store.CreateRole(ctx, store.CreateRoleParams{ + GuildID: guildID, + Name: name, + Permissions: uint64(permissionSet), + Color: input.Body.Color, + Hoist: input.Body.Hoist, + Mentionable: input.Body.Mentionable, + }) + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "role.create", "role", &role.ID, "") + s.dispatchGuildEvent(guildID, "GUILD_ROLE_CREATE", roleFromStore(*role)) + output := &roleOutput{} + output.Body.Role = roleFromStore(*role) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateGuildRole", + Method: http.MethodPatch, + Path: "/guilds/{guild_id}/roles/{role_id}", + Summary: "Изменить роль", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + RoleID string `path:"role_id"` + Body struct { + Name *string `json:"name,omitempty" maxLength:"64"` + Permissions *string `json:"permissions,omitempty"` + Color *int64 `json:"color,omitempty" minimum:"0" maximum:"16777215"` + Position *int `json:"position,omitempty" minimum:"0" maximum:"1000"` + Hoist *bool `json:"hoist,omitempty"` + Mentionable *bool `json:"mentionable,omitempty"` + } + }, + ) (*roleOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + roleID, err := parseID("role_id", input.RoleID) + if err != nil { + return nil, err + } + role, err := s.store.GetRole(ctx, roleID) + if err != nil { + return nil, humaError(err) + } + if role.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found") + } + if !s.outranksRole(ctx, guildID, user, resolved, role) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours") + } + params := store.UpdateRoleParams{ + Name: input.Body.Name, + Color: input.Body.Color, + Position: input.Body.Position, + Hoist: input.Body.Hoist, + Mentionable: input.Body.Mentionable, + } + if input.Body.Permissions != nil { + permissionSet, err := permissions.Parse(*input.Body.Permissions) + if err != nil { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", err.Error()) + } + if !s.canGrantPermissions(ctx, guildID, user, permissionSet) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "you cannot grant permissions you do not have") + } + value := uint64(permissionSet) + params.Permissions = &value + } + updated, err := s.store.UpdateRole(ctx, roleID, params) + if err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "role.update", "role", &roleID, "") + s.dispatchGuildEvent(guildID, "GUILD_ROLE_UPDATE", roleFromStore(*updated)) + output := &roleOutput{} + output.Body.Role = roleFromStore(*updated) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "deleteGuildRole", + Method: http.MethodDelete, + Path: "/guilds/{guild_id}/roles/{role_id}", + Summary: "Удалить роль", + Tags: []string{"Roles"}, + Security: security, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + RoleID string `path:"role_id"` + }, + ) (*okOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guildID, resolved, err := s.requireGuildPermission(ctx, input.GuildID, user, permissions.ManageRoles) + if err != nil { + return nil, err + } + roleID, err := parseID("role_id", input.RoleID) + if err != nil { + return nil, err + } + role, err := s.store.GetRole(ctx, roleID) + if err != nil { + return nil, humaError(err) + } + if role.GuildID != guildID { + return nil, humaErrorStatus(http.StatusNotFound, "not_found", "role not found") + } + if role.IsDefault { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "default role cannot be deleted") + } + if !s.outranksRole(ctx, guildID, user, resolved, role) { + return nil, humaErrorStatus(http.StatusForbidden, "perm.denied", "role is higher than yours") + } + if err := s.store.DeleteRole(ctx, roleID); err != nil { + return nil, humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "role.delete", "role", &roleID, "") + s.dispatchGuildEvent(guildID, "GUILD_ROLE_DELETE", map[string]any{ + "guild_id": formatSnowflake(guildID), + "role_id": formatSnowflake(roleID), + }) + return newOKOutput(), nil + }) +} + +// canGrantPermissions запрещает выдавать права выше собственных (AGENT.md 6.3). +func (s *Server) canGrantPermissions(ctx context.Context, guildID uint64, user *store.User, requested permissions.Permission) bool { + if user.IsInstanceAdmin { + return true + } + resolved, err := s.guildPermissions(ctx, guildID, user) + if err != nil { + return false + } + if resolved.IsOwner || resolved.Guild.Has(permissions.Administrator) { + return true + } + return resolved.Guild.Has(requested) +} + +// memberFromProfile собирает участника для ответа API. +func memberFromProfile(profile store.GuildMemberProfile) memberPayload { + payload := memberPayload{ + UserID: formatSnowflake(profile.UserID), + Username: profile.Username, + DisplayName: profile.DisplayName, + Nickname: profile.Nickname, + Status: profile.Status, + CustomStatus: profile.CustomStatus, + IsInstanceAdmin: profile.IsInstanceAdmin, + JoinedAt: profile.JoinedAt.UTC().Format(time.RFC3339), + RoleIDs: make([]string, 0, len(profile.RoleIDs)), + } + if profile.AvatarFileID != nil { + payload.AvatarFileID = formatSnowflake(*profile.AvatarFileID) + } + if profile.TimeoutUntil != nil { + payload.TimeoutUntil = profile.TimeoutUntil.UTC().Format(time.RFC3339) + } + for _, roleID := range profile.RoleIDs { + payload.RoleIDs = append(payload.RoleIDs, formatSnowflake(roleID)) + } + return payload +} + +type auditEntryPayload struct { + ID string `json:"id"` + GuildID string `json:"guild_id,omitempty"` + ActorID string `json:"actor_id,omitempty"` + ActorInstanceAdmin bool `json:"actor_instance_admin"` + Action string `json:"action"` + TargetType string `json:"target_type"` + TargetID string `json:"target_id,omitempty"` + Reason string `json:"reason,omitempty"` + Changes json.RawMessage `json:"changes,omitempty"` + CreatedAt string `json:"created_at"` +} + +type auditListOutput struct { + Body struct { + Entries []auditEntryPayload `json:"entries"` + } +} + +func auditPayloads(entries []store.AuditEntry) []auditEntryPayload { + payloads := make([]auditEntryPayload, 0, len(entries)) + for _, entry := range entries { + payload := auditEntryPayload{ + ID: formatSnowflake(entry.ID), + Action: entry.Action, + TargetType: entry.TargetType, + Reason: entry.Reason, + Changes: entry.Changes, + CreatedAt: entry.CreatedAt.UTC().Format(time.RFC3339), + ActorInstanceAdmin: entry.ActorInstanceAdmin, + } + if entry.GuildID != nil { + payload.GuildID = formatSnowflake(*entry.GuildID) + } + if entry.ActorID != nil { + payload.ActorID = formatSnowflake(*entry.ActorID) + } + if entry.TargetID != nil { + payload.TargetID = formatSnowflake(*entry.TargetID) + } + payloads = append(payloads, payload) + } + return payloads +} + +// recordAudit пишет действие в журнал; ошибка записи не ломает операцию. +func (s *Server) recordAudit(ctx context.Context, actor *store.User, guildID uint64, action, targetType string, targetID *uint64, reason string) { + var guild *uint64 + if guildID != 0 { + guild = &guildID + } + params := store.AuditParams{ + GuildID: guild, + ActorID: &actor.ID, + ActorInstanceAdmin: actor.IsInstanceAdmin, + Action: action, + TargetType: targetType, + TargetID: targetID, + Reason: reason, + } + if err := s.store.RecordAudit(ctx, params); err != nil { + s.logger.WarnContext(ctx, "failed to record audit entry", + slog.String("action", action), slog.Any("error", err)) + } +} + +// createGuild создаёт сервер с ролями по умолчанию и общей комнатой (AGENT.md 6.3). +func (s *Server) createGuild(ctx context.Context, owner *store.User, name string) (*store.Guild, error) { + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + // Лимиты считаются по числу серверов; администратор инстанса обходит их + // осознанно, и это фиксируется в аудите (AGENT.md 6.5). + created, err := s.store.CountGuildMemberships(ctx, owner.ID) + if err != nil { + return nil, humaError(err) + } + bypassed := false + if created >= settings.MaxGuildsPerUser { + if !owner.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "limits.guilds_reached", "guild limit for this account is reached") + } + bypassed = true + } else if !settings.AllowGuildCreation && !owner.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "limits.guild_creation_disabled", "guild creation is disabled") + } + + guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{ + Name: name, + OwnerID: owner.ID, + // Пользовательский сервер становится главным только если главного нет. + IsMain: settings.MainGuildID == 0, + IsDiscoverable: false, + }) + if err != nil { + return nil, humaError(err) + } + if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil { + return nil, humaError(err) + } + if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, + }); err != nil { + return nil, humaError(err) + } + if guild.IsMain { + if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { + return nil, humaError(err) + } + } + s.recordAudit(ctx, owner, guild.ID, "guild.create", "guild", &guild.ID, "") + if bypassed { + s.recordAudit(ctx, owner, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limit") + } + if s.gateway != nil { + s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)}) + } + return guild, nil +} + +// joinGuild добавляет пользователя в открытый сервер: главный сервер открыт +// всегда, остальные — если помечены как discoverable (AGENT.md 6.3). +func (s *Server) joinGuild(ctx context.Context, user *store.User, guildID uint64) error { + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return humaError(err) + } + if !guild.IsMain && !guild.IsDiscoverable && !user.IsInstanceAdmin { + return humaErrorStatus(http.StatusForbidden, "guild.invite_required", "this guild requires an invite") + } + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return humaError(err) + } + count, err := s.store.CountGuildMembers(ctx, guildID) + if err != nil { + return humaError(err) + } + if count >= settings.MaxMembersPerGuild && !user.IsInstanceAdmin { + return humaErrorStatus(http.StatusForbidden, "limits.members_reached", "guild is full") + } + if _, err := s.store.AddGuildMember(ctx, guildID, user.ID, ""); err != nil { + if errors.Is(err, store.ErrConflict) { + return nil + } + return humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, user, guildID, "member.join", "user", &user.ID, "") + if s.gateway != nil { + s.gateway.SendToUser(user.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guildID)}) + } + return nil +} + +// deleteGuild удаляет сервер: главный сервер удалить нельзя (AGENT.md 6.3). +func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uint64) error { + guild, err := s.store.GetGuild(ctx, guildID) + if err != nil { + return humaError(err) + } + if guild.IsMain { + return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted") + } + if err := s.store.DeleteGuild(ctx, guildID); err != nil { + return humaError(err) + } + s.invalidateGuild(guildID) + s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "") + if s.gateway != nil { + s.gateway.Dispatch("GUILD_DELETE", map[string]any{"guild_id": formatSnowflake(guildID)}) + } + return nil +} diff --git a/internal/server/api_instance.go b/internal/server/api_instance.go new file mode 100644 index 0000000..e4c503c --- /dev/null +++ b/internal/server/api_instance.go @@ -0,0 +1,483 @@ +package server + +import ( + "context" + "net/http" + "strconv" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/bootstrap" + "glchat/internal/store" +) + +// instancePayload — публичная информация об инстансе (AGENT.md 6.5). +type instancePayload struct { + Name string `json:"name"` + Version string `json:"version"` + RegistrationEnabled bool `json:"registration_enabled"` + AllowGuildCreation bool `json:"allow_guild_creation"` + VoiceEnabled bool `json:"voice_enabled"` + MaxGuildsPerUser int `json:"max_guilds_per_user"` + MaxMembersPerGuild int `json:"max_members_per_guild"` + MaxMessageLength int `json:"max_message_length"` + MainGuildID string `json:"main_guild_id,omitempty"` + UserCount int `json:"user_count"` + GuildCount int `json:"guild_count"` +} + +type instanceOutput struct { + Body struct { + Instance instancePayload `json:"instance"` + } +} + +type instanceGuildPayload struct { + ID string `json:"id"` + Name string `json:"name"` + OwnerID string `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` + IsMain bool `json:"is_main"` + MemberCount int `json:"member_count"` + CreatedAt string `json:"created_at"` +} + +type instanceGuildListOutput struct { + Body struct { + Guilds []instanceGuildPayload `json:"guilds"` + } +} + +type instanceUserPayload struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + IsInstanceAdmin bool `json:"is_instance_admin"` + Badges []string `json:"badges"` + CreatedAt string `json:"created_at"` +} + +type instanceUserListOutput struct { + Body struct { + Users []instanceUserPayload `json:"users"` + } +} + +type instanceSettingsPayload struct { + RegistrationEnabled bool `json:"registration_enabled"` + AllowGuildCreation bool `json:"allow_guild_creation"` + MaxGuildsPerUser int `json:"max_guilds_per_user"` + MaxMembersPerGuild int `json:"max_members_per_guild"` + MaxMessageLength int `json:"max_message_length"` +} + +type instanceSettingsOutput struct { + Body struct { + Settings instanceSettingsPayload `json:"settings"` + } +} + +// registerInstanceRoutes описывает публичную информацию об инстансе и +// админ-панель администратора инстанса (AGENT.md 6.5, 7.19). +func (s *Server) registerInstanceRoutes(api huma.API) { + huma.Register(api, huma.Operation{ + OperationID: "getInstance", + Method: http.MethodGet, + Path: "/instance", + Summary: "Публичная информация об инстансе", + Tags: []string{"Instance"}, + }, func(ctx context.Context, _ *struct{}) (*instanceOutput, error) { + payload, err := s.instancePayload(ctx) + if err != nil { + return nil, err + } + // Лимиты и число пользователей видны только администратору инстанса. + if user, _, ok := sessionFromContext(ctx); !ok || !user.IsInstanceAdmin { + payload.MaxGuildsPerUser = 0 + payload.MaxMembersPerGuild = 0 + payload.UserCount = 0 + payload.GuildCount = 0 + } + output := &instanceOutput{} + output.Body.Instance = payload + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getInstanceSettings", + Method: http.MethodGet, + Path: "/instance/settings", + Summary: "Настройки инстанса (только администратор)", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, _ *struct{}) (*instanceSettingsOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + output := &instanceSettingsOutput{} + output.Body.Settings = instanceSettingsPayload{ + RegistrationEnabled: settings.RegistrationEnabled, + AllowGuildCreation: settings.AllowGuildCreation, + MaxGuildsPerUser: settings.MaxGuildsPerUser, + MaxMembersPerGuild: settings.MaxMembersPerGuild, + MaxMessageLength: settings.MaxMessageLength, + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateInstanceSettings", + Method: http.MethodPatch, + Path: "/instance/settings", + Summary: "Изменить настройки инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + Body struct { + RegistrationEnabled *bool `json:"registration_enabled,omitempty"` + AllowGuildCreation *bool `json:"allow_guild_creation,omitempty"` + MaxGuildsPerUser *int `json:"max_guilds_per_user,omitempty" minimum:"1" maximum:"10000"` + MaxMembersPerGuild *int `json:"max_members_per_guild,omitempty" minimum:"1" maximum:"1000000"` + MaxMessageLength *int `json:"max_message_length,omitempty" minimum:"1" maximum:"100000"` + } + }, + ) (*instanceSettingsOutput, error) { + user, err := requireInstanceAdmin(ctx) + if err != nil { + return nil, err + } + updates := map[string]string{} + if input.Body.RegistrationEnabled != nil { + updates["registration_enabled"] = strconv.FormatBool(*input.Body.RegistrationEnabled) + } + if input.Body.AllowGuildCreation != nil { + updates["allow_guild_creation"] = strconv.FormatBool(*input.Body.AllowGuildCreation) + } + if input.Body.MaxGuildsPerUser != nil { + updates["max_guilds_per_user"] = strconv.Itoa(*input.Body.MaxGuildsPerUser) + } + if input.Body.MaxMembersPerGuild != nil { + updates["max_members_per_guild"] = strconv.Itoa(*input.Body.MaxMembersPerGuild) + } + if input.Body.MaxMessageLength != nil { + updates["max_message_length"] = strconv.Itoa(*input.Body.MaxMessageLength) + } + for key, value := range updates { + if err := s.store.SetInstanceSetting(ctx, key, value); err != nil { + return nil, humaError(err) + } + } + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + s.recordAudit(ctx, user, 0, "instance.settings_update", "instance", nil, "") + output := &instanceSettingsOutput{} + output.Body.Settings = instanceSettingsPayload{ + RegistrationEnabled: settings.RegistrationEnabled, + AllowGuildCreation: settings.AllowGuildCreation, + MaxGuildsPerUser: settings.MaxGuildsPerUser, + MaxMembersPerGuild: settings.MaxMembersPerGuild, + MaxMessageLength: settings.MaxMessageLength, + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listInstanceGuilds", + Method: http.MethodGet, + Path: "/instance/guilds", + Summary: "Все серверы инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, _ *struct{}) (*instanceGuildListOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + guilds, err := s.store.ListAllGuilds(ctx) + if err != nil { + return nil, humaError(err) + } + output := &instanceGuildListOutput{} + output.Body.Guilds = make([]instanceGuildPayload, 0, len(guilds)) + for _, guild := range guilds { + payload := instanceGuildPayload{ + ID: formatSnowflake(guild.ID), + Name: guild.Name, + OwnerID: formatSnowflake(guild.OwnerID), + IsMain: guild.IsMain, + CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339), + } + if owner, err := s.store.GetUser(ctx, guild.OwnerID); err == nil { + payload.OwnerName = owner.Username + } + if count, err := s.store.CountGuildMembers(ctx, guild.ID); err == nil { + payload.MemberCount = count + } + output.Body.Guilds = append(output.Body.Guilds, payload) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminCreateGuild", + Method: http.MethodPost, + Path: "/instance/guilds", + Summary: "Создать сервер от имени администратора (лимиты обходятся)", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + Body struct { + Name string `json:"name" minLength:"1" maxLength:"64"` + OwnerID string `json:"owner_id,omitempty"` + } + }, + ) (*instanceGuildListOutput, error) { + admin, err := requireInstanceAdmin(ctx) + if err != nil { + return nil, err + } + owner := admin + if input.Body.OwnerID != "" { + ownerID, err := parseID("owner_id", input.Body.OwnerID) + if err != nil { + return nil, err + } + owner, err = s.store.GetUser(ctx, ownerID) + if err != nil { + return nil, humaError(err) + } + } + name := strings.TrimSpace(input.Body.Name) + if name == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "guild name must not be empty") + } + // Администратор инстанса создаёт сервер в обход лимитов: причина + // фиксируется в аудите отдельной записью (AGENT.md 6.5). + guild, err := s.createGuildAsAdmin(ctx, admin, owner, name) + if err != nil { + return nil, err + } + output := &instanceGuildListOutput{} + output.Body.Guilds = []instanceGuildPayload{{ + ID: formatSnowflake(guild.ID), + Name: guild.Name, + OwnerID: formatSnowflake(guild.OwnerID), + IsMain: guild.IsMain, + CreatedAt: guild.CreatedAt.UTC().Format(time.RFC3339), + }} + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "adminDeleteGuild", + Method: http.MethodDelete, + Path: "/instance/guilds/{guild_id}", + Summary: "Удалить сервер (администратор инстанса)", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + GuildID string `path:"guild_id"` + }, + ) (*okOutput, error) { + admin, err := requireInstanceAdmin(ctx) + if err != nil { + return nil, err + } + guildID, err := parseID("guild_id", input.GuildID) + if err != nil { + return nil, err + } + if err := s.deleteGuild(ctx, admin, guildID); err != nil { + return nil, err + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listInstanceUsers", + Method: http.MethodGet, + Path: "/instance/users", + Summary: "Пользователи инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + Offset int `query:"offset" default:"0" minimum:"0"` + }, + ) (*instanceUserListOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + users, err := s.store.ListUsers(ctx, input.Limit, input.Offset) + if err != nil { + return nil, humaError(err) + } + output := &instanceUserListOutput{} + output.Body.Users = make([]instanceUserPayload, 0, len(users)) + for _, user := range users { + badges := user.Badges + if badges == nil { + badges = []string{} + } + output.Body.Users = append(output.Body.Users, instanceUserPayload{ + ID: formatSnowflake(user.ID), + Username: user.Username, + DisplayName: user.DisplayName, + IsInstanceAdmin: user.IsInstanceAdmin, + Badges: badges, + CreatedAt: user.CreatedAt.UTC().Format(time.RFC3339), + }) + } + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "setInstanceAdmin", + Method: http.MethodPost, + Path: "/instance/users/{user_id}/admin", + Summary: "Выдать или снять права администратора инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + Body struct { + Admin bool `json:"admin"` + // StepUpPassword подтверждает действие: смена администраторов — + // чувствительная операция (AGENT.md 7.1). + StepUpPassword string `json:"step_up_password,omitempty"` + StepUpTOTP string `json:"step_up_totp,omitempty"` + } + }, + ) (*userOutput, error) { + admin, session, err := requireUser(ctx) + if err != nil { + return nil, err + } + if !admin.IsInstanceAdmin { + return nil, humaErrorStatus(http.StatusForbidden, "instance.admin_required", "instance administrator rights required") + } + if err := s.auth.RequireStepUp(ctx, admin, session, input.Body.StepUpPassword, input.Body.StepUpTOTP); err != nil { + return nil, humaError(err) + } + userID, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + if userID == admin.ID && !input.Body.Admin { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "you cannot revoke your own administrator rights") + } + if err := s.store.SetInstanceAdmin(ctx, userID, input.Body.Admin); err != nil { + return nil, humaError(err) + } + updated, err := s.store.GetUser(ctx, userID) + if err != nil { + return nil, humaError(err) + } + action := "instance.admin_grant" + if !input.Body.Admin { + action = "instance.admin_revoke" + } + s.recordAudit(ctx, admin, 0, action, "user", &userID, "") + s.dispatchUserUpdate(updated) + output := &userOutput{} + output.Body.User = profileFromUser(updated, false) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listInstanceAudit", + Method: http.MethodGet, + Path: "/instance/audit", + Summary: "Журнал действий администраторов инстанса", + Tags: []string{"Instance"}, + Security: []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}}, + }, func(ctx context.Context, input *struct { + Limit int `query:"limit" default:"50" minimum:"1" maximum:"200"` + }, + ) (*auditListOutput, error) { + if _, err := requireInstanceAdmin(ctx); err != nil { + return nil, err + } + entries, err := s.store.ListInstanceAudit(ctx, input.Limit) + if err != nil { + return nil, humaError(err) + } + output := &auditListOutput{} + output.Body.Entries = auditPayloads(entries) + return output, nil + }) +} + +// instancePayload собирает публичные сведения об инстансе. +func (s *Server) instancePayload(ctx context.Context) (instancePayload, error) { + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return instancePayload{}, humaError(err) + } + users, err := s.store.CountUsers(ctx) + if err != nil { + return instancePayload{}, humaError(err) + } + guilds, err := s.store.ListAllGuilds(ctx) + if err != nil { + return instancePayload{}, humaError(err) + } + payload := instancePayload{ + Name: s.cfg.InstanceName, + Version: s.cfg.Version, + RegistrationEnabled: settings.RegistrationEnabled, + AllowGuildCreation: settings.AllowGuildCreation, + MaxGuildsPerUser: settings.MaxGuildsPerUser, + MaxMembersPerGuild: settings.MaxMembersPerGuild, + MaxMessageLength: settings.MaxMessageLength, + UserCount: users, + GuildCount: len(guilds), + } + if settings.MainGuildID != 0 { + payload.MainGuildID = formatSnowflake(settings.MainGuildID) + } + return payload, nil +} + +// createGuildAsAdmin создаёт сервер в обход лимитов и фиксирует это в аудите. +func (s *Server) createGuildAsAdmin(ctx context.Context, admin, owner *store.User, name string) (*store.Guild, error) { + settings, err := s.store.InstanceSettings(ctx) + if err != nil { + return nil, humaError(err) + } + guild, err := s.store.CreateGuild(ctx, store.CreateGuildParams{ + Name: name, + OwnerID: owner.ID, + IsMain: settings.MainGuildID == 0, + IsDiscoverable: false, + }) + if err != nil { + return nil, humaError(err) + } + if _, err := bootstrap.SeedGuildDefaults(ctx, s.store, guild, owner); err != nil { + return nil, humaError(err) + } + if _, err := s.store.CreateChannel(ctx, store.CreateChannelParams{ + GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0, + }); err != nil { + return nil, humaError(err) + } + if guild.IsMain { + if err := s.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil { + return nil, humaError(err) + } + } + s.recordAudit(ctx, admin, guild.ID, "guild.create", "guild", &guild.ID, "created by instance admin") + s.recordAudit(ctx, admin, guild.ID, "limits.bypass", "guild", &guild.ID, "instance admin bypassed guild limits") + if s.gateway != nil { + s.gateway.SendToUser(owner.ID, "GUILD_CREATE", map[string]any{"guild_id": formatSnowflake(guild.ID)}) + } + return guild, nil +} diff --git a/internal/server/api_test.go b/internal/server/api_test.go new file mode 100644 index 0000000..c0d0471 --- /dev/null +++ b/internal/server/api_test.go @@ -0,0 +1,631 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// permissionViewChannel — сокращение для оверрайдов в тестах. +const permissionViewChannel = permissions.ViewChannel + +// registerAndLogin регистрирует пользователя и возвращает cookie сессии. +func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie { + t.Helper() + rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", + `{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`) + if rec.Code != http.StatusOK { + t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String()) + } + cookies := rec.Result().Cookies() + if len(cookies) == 0 { + t.Fatalf("register %s did not return a session cookie", username) + } + return cookies[0] +} + +func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T { + t.Helper() + var payload T + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String()) + } + return payload +} + +func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + payload := decodeResponse[struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + }](t, rec) + if payload.Error.Code == "" { + t.Fatalf("response has no error code: %s", rec.Body.String()) + } + return payload.Error.Code +} + +// promoteAdmin делает пользователя администратором инстанса напрямую в БД. +func promoteAdmin(t *testing.T, srv *Server, email string) { + t.Helper() + user, err := srv.auth.UserByEmail(t.Context(), email) + if err != nil { + t.Fatalf("find user %s: %v", email, err) + } + if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil { + t.Fatalf("promote %s: %v", email, err) + } +} + +func TestHumaErrorsUseAPIEnvelope(t *testing.T) { + srv, _ := newTestServer(t) + + rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + if code := errorCodeOf(t, rec); code != "auth.session_expired" { + t.Fatalf("error code = %q, want auth.session_expired", code) + } + + rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("instance settings without session = %d, want 401", rec.Code) + } +} + +func TestProfileUpdateFlow(t *testing.T) { + srv, _ := newTestServer(t) + cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com") + + me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) + if me.Code != http.StatusOK { + t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String()) + } + profile := decodeResponse[struct { + User struct { + Username string `json:"username"` + OnboardingCompleted bool `json:"onboarding_completed"` + Locale string `json:"locale"` + } `json:"user"` + }](t, me) + if profile.User.Username != "profile_user" { + t.Fatalf("username = %q", profile.User.Username) + } + if profile.User.OnboardingCompleted { + t.Fatal("new user must not have onboarding completed") + } + if profile.User.Locale != "ru" { + t.Fatalf("default locale = %q, want ru", profile.User.Locale) + } + + updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", + `{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie) + if updated.Code != http.StatusOK { + t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String()) + } + payload := decodeResponse[struct { + User struct { + DisplayName string `json:"display_name"` + Bio string `json:"bio"` + Status string `json:"status"` + Locale string `json:"locale"` + } `json:"user"` + }](t, updated) + if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" { + t.Fatalf("unexpected profile: %+v", payload.User) + } + + // Онбординг завершается отдельной ручкой и выставляет флаг. + onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete", + `{"display_name":"Новый ник","bio":"привет"}`, cookie) + if onboarding.Code != http.StatusOK { + t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String()) + } + after := decodeResponse[struct { + User struct { + DisplayName string `json:"display_name"` + OnboardingCompleted bool `json:"onboarding_completed"` + } `json:"user"` + }](t, onboarding) + if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" { + t.Fatalf("unexpected onboarding result: %+v", after.User) + } + + // Публичный профиль доступен другому пользователю и не содержит приватных полей. + otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com") + user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie) + if public.Code != http.StatusOK { + t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String()) + } + if strings.Contains(public.Body.String(), "@example.com") { + t.Fatalf("public profile must not leak email: %s", public.Body.String()) + } +} + +func TestGuildLifecycleAndPermissions(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com") + memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie) + if created.Code != http.StatusOK { + t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String()) + } + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + Name string `json:"name"` + IsMain bool `json:"is_main"` + Roles []struct { + ID string `json:"id"` + IsDefault bool `json:"is_default"` + } `json:"roles"` + Channels []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"channels"` + MyPermissions []string `json:"my_permissions"` + } `json:"guild"` + }](t, created) + if guild.Guild.Name != "Тестовый сервер" { + t.Fatalf("guild name = %q", guild.Guild.Name) + } + if len(guild.Guild.Roles) != 2 { + t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles)) + } + if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" { + t.Fatalf("default channels = %+v", guild.Guild.Channels) + } + if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") { + t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions) + } + + // Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7). + stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) + if stranger.Code != http.StatusNotFound { + t.Fatalf("stranger channels = %d, want 404", stranger.Code) + } + + // Главный сервер открыт для присоединения. + join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) + if join.Code != http.StatusOK { + t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String()) + } + + // Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS. + channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) + if channels.Code != http.StatusOK { + t.Fatalf("member channels = %d", channels.Code) + } + list := decodeResponse[struct { + Channels []struct { + Name string `json:"name"` + CanView bool `json:"can_view"` + CanSend bool `json:"can_send"` + } `json:"channels"` + }](t, channels) + if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend { + t.Fatalf("unexpected member channels: %+v", list.Channels) + } + denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", + `{"name":"секретная","type":"text"}`, memberCookie) + if denied.Code != http.StatusForbidden { + t.Fatalf("member create channel = %d, want 403", denied.Code) + } + if code := errorCodeOf(t, denied); code != "perm.denied" { + t.Fatalf("error code = %q, want perm.denied", code) + } + + // Владелец создаёт комнату и роль. + newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", + `{"name":"флудилка","type":"voice"}`, ownerCookie) + if newChannel.Code != http.StatusOK { + t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String()) + } + + roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles", + `{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie) + if roleRec.Code != http.StatusOK { + t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String()) + } + role := decodeResponse[struct { + Role struct { + ID string `json:"id"` + Permissions string `json:"permissions"` + } `json:"role"` + }](t, roleRec) + if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") { + t.Fatalf("role permissions = %q", role.Role.Permissions) + } + + // Роль можно выдать участнику. + member, err := srv.auth.UserByEmail(t.Context(), "member@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + assign := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie) + if assign.Code != http.StatusOK { + t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String()) + } + members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie) + roster := decodeResponse[struct { + Members []struct { + UserID string `json:"user_id"` + RoleIDs []string `json:"role_ids"` + } `json:"members"` + }](t, members) + if len(roster.Members) != 2 { + t.Fatalf("members = %d, want 2", len(roster.Members)) + } + found := false + for _, entry := range roster.Members { + if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) { + found = true + } + } + if !found { + t.Fatalf("role was not assigned: %+v", roster.Members) + } + + // Участник не может исключить владельца даже с правом KICK_MEMBERS. + owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + kickOwner := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie) + if kickOwner.Code != http.StatusForbidden { + t.Fatalf("kick owner = %d, want 403", kickOwner.Code) + } + + // Владелец исключает участника. + kick := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie) + if kick.Code != http.StatusOK { + t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String()) + } + + // Журнал аудита содержит действие исключения. + audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie) + entries := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + } `json:"entries"` + }](t, audit) + if !hasAction(entries.Entries, "member.kick") { + t.Fatalf("audit log has no member.kick: %+v", entries.Entries) + } + + // Главный сервер удалить нельзя. + deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie) + if deleteMain.Code != http.StatusForbidden { + t.Fatalf("delete main guild = %d, want 403", deleteMain.Code) + } +} + +func TestInstanceAdminEndpointsAndLimits(t *testing.T) { + srv, _ := newTestServer(t) + adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com") + promoteAdmin(t, srv, "admin@example.com") + userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com") + + // Публичная информация об инстансе доступна без сессии. + public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "") + if public.Code != http.StatusOK { + t.Fatalf("GET /instance = %d", public.Code) + } + publicPayload := decodeResponse[struct { + Instance struct { + RegistrationEnabled bool `json:"registration_enabled"` + UserCount int `json:"user_count"` + } `json:"instance"` + }](t, public) + if !publicPayload.Instance.RegistrationEnabled { + t.Fatal("registration must be enabled by default") + } + if publicPayload.Instance.UserCount != 0 { + t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount) + } + + // Обычный пользователь не имеет доступа к админ-ручкам. + for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} { + rec := doJSON(t, srv, http.MethodGet, path, "", userCookie) + if rec.Code != http.StatusForbidden { + t.Fatalf("GET %s as user = %d, want 403", path, rec.Code) + } + if code := errorCodeOf(t, rec); code != "instance.admin_required" { + t.Fatalf("GET %s code = %q", path, code) + } + } + + // Лимит в один сервер на пользователя. + patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", + `{"max_guilds_per_user":1}`, adminCookie) + if patch.Code != http.StatusOK { + t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String()) + } + + first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie) + if first.Code != http.StatusOK { + t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String()) + } + second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie) + if second.Code != http.StatusForbidden { + t.Fatalf("second guild = %d, want 403", second.Code) + } + if code := errorCodeOf(t, second); code != "limits.guilds_reached" { + t.Fatalf("limit error code = %q, want limits.guilds_reached", code) + } + + // Администратор инстанса обходит лимит, и это попадает в аудит. + adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie) + if adminGuild.Code != http.StatusOK { + t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String()) + } + + guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie) + guildList := decodeResponse[struct { + Guilds []struct { + Name string `json:"name"` + } `json:"guilds"` + }](t, guilds) + if len(guildList.Guilds) != 2 { + t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds)) + } + + audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie) + entries := decodeResponse[struct { + Entries []struct { + Action string `json:"action"` + ActorInstanceAdmin bool `json:"actor_instance_admin"` + } `json:"entries"` + }](t, audit) + if !hasAction(entries.Entries, "limits.bypass") { + t.Fatalf("audit has no limits.bypass: %+v", entries.Entries) + } + for _, entry := range entries.Entries { + if !entry.ActorInstanceAdmin { + t.Fatalf("instance audit entry without admin flag: %+v", entry) + } + } + + // Права администратора выдаются только со step-up. + user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + noStepUp := doJSON(t, srv, http.MethodPost, + "/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie) + if noStepUp.Code != http.StatusForbidden { + t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code) + } + grant := doJSON(t, srv, http.MethodPost, + "/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", + `{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie) + if grant.Code != http.StatusOK { + t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String()) + } + + // Смена настроек регистрации закрывает регистрацию. + off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie) + if off.Code != http.StatusOK { + t.Fatalf("disable registration = %d", off.Code) + } + blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", + `{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`) + if blocked.Code == http.StatusOK { + t.Fatal("registration must be rejected when disabled") + } +} + +func TestMemberTimeoutRequiresPermission(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com") + memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) + + member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + // У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён. + denied := doJSON(t, srv, http.MethodPatch, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), + `{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie) + if denied.Code != http.StatusForbidden { + t.Fatalf("member self timeout = %d, want 403", denied.Code) + } + + // Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются. + ok := doJSON(t, srv, http.MethodPatch, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), + `{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie) + if ok.Code != http.StatusOK { + t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String()) + } + channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) + list := decodeResponse[struct { + Channels []struct { + CanSend bool `json:"can_send"` + } `json:"channels"` + }](t, channels) + if len(list.Channels) == 0 || list.Channels[0].CanSend { + t.Fatalf("timed out member must not be able to send: %+v", list.Channels) + } +} + +func TestRoleHierarchyProtectsHigherRoles(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com") + modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + } `json:"guild"` + }](t, created) + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie) + + mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com") + if err != nil { + t.Fatalf("UserByEmail: %v", err) + } + // Роль модератора с правом управления ролями. + roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles", + `{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie) + role := decodeResponse[struct { + Role struct { + ID string `json:"id"` + } `json:"role"` + }](t, roleRec) + assign := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie) + if assign.Code != http.StatusOK { + t.Fatalf("assign moderator role = %d", assign.Code) + } + + // Модератор пытается снять собственную роль: себе модерировать нельзя. + self := doJSON(t, srv, http.MethodDelete, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie) + if self.Code != http.StatusForbidden { + t.Fatalf("self moderation = %d, want 403", self.Code) + } + + // Модератор пытается выдать себе роль администратора: она выше его роли. + roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie) + roleList := decodeResponse[struct { + Roles []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"roles"` + }](t, roles) + var adminRoleID string + for _, candidate := range roleList.Roles { + if candidate.Name == "Администратор" { + adminRoleID = candidate.ID + } + } + if adminRoleID == "" { + t.Fatal("administrator role is missing") + } + escalate := doJSON(t, srv, http.MethodPut, + "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie) + if escalate.Code != http.StatusForbidden { + t.Fatalf("privilege escalation = %d, want 403", escalate.Code) + } +} + +func TestChannelOverridesHideChannel(t *testing.T) { + srv, _ := newTestServer(t) + ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com") + memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com") + + created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie) + guild := decodeResponse[struct { + Guild struct { + ID string `json:"id"` + Roles []struct { + ID string `json:"id"` + IsDefault bool `json:"is_default"` + } `json:"roles"` + } `json:"guild"` + }](t, created) + doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) + + secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", + `{"name":"тайная","type":"text"}`, ownerCookie) + channel := decodeResponse[struct { + Channel struct { + ID string `json:"id"` + } `json:"channel"` + }](t, secret) + + // Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён. + channelID, err := parseID("channel_id", channel.Channel.ID) + if err != nil { + t.Fatalf("parse channel id: %v", err) + } + var defaultRoleID uint64 + for _, role := range guild.Guild.Roles { + if role.IsDefault { + defaultRoleID, err = parseID("role_id", role.ID) + if err != nil { + t.Fatalf("parse role id: %v", err) + } + } + } + if defaultRoleID == 0 { + t.Fatal("default role not found") + } + if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{ + ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID, + Deny: uint64(permissionViewChannel), + }); err != nil { + t.Fatalf("SetChannelOverride: %v", err) + } + srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID)) + + channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) + list := decodeResponse[struct { + Channels []struct { + Name string `json:"name"` + } `json:"channels"` + }](t, channels) + for _, entry := range list.Channels { + if entry.Name == "тайная" { + t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels) + } + } +} + +func guildIDOf(t *testing.T, raw string) uint64 { + t.Helper() + id, err := parseID("guild_id", raw) + if err != nil { + t.Fatalf("parse guild id: %v", err) + } + return id +} + +func containsString(values []string, wanted string) bool { + for _, value := range values { + if value == wanted { + return true + } + } + return false +} + +func hasAction[T any](entries []T, action string) bool { + for _, entry := range entries { + encoded, err := json.Marshal(entry) + if err != nil { + continue + } + if strings.Contains(string(encoded), `"`+action+`"`) { + return true + } + } + return false +} diff --git a/internal/server/api_users.go b/internal/server/api_users.go new file mode 100644 index 0000000..582a61b --- /dev/null +++ b/internal/server/api_users.go @@ -0,0 +1,339 @@ +package server + +import ( + "context" + "net/http" + "strings" + + "github.com/danielgtaylor/huma/v2" + + "glchat/internal/permissions" + "glchat/internal/store" +) + +// profilePayload — публичный профиль пользователя (AGENT.md 8.2). +type profilePayload struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Bio string `json:"bio"` + Status string `json:"status"` + CustomStatus string `json:"custom_status"` + AvatarFileID string `json:"avatar_file_id,omitempty"` + BannerFileID string `json:"banner_file_id,omitempty"` + IsInstanceAdmin bool `json:"is_instance_admin"` + Badges []string `json:"badges"` + Locale string `json:"locale"` + // OnboardingCompleted — признак пройденной первичной настройки (AGENT.md 7.2). + OnboardingCompleted bool `json:"onboarding_completed"` +} + +func profileFromUser(user *store.User, includePrivate bool) profilePayload { + payload := profilePayload{ + ID: formatSnowflake(user.ID), + Username: user.Username, + DisplayName: user.DisplayName, + Bio: user.Bio, + Status: user.Status, + CustomStatus: user.CustomStatus, + IsInstanceAdmin: user.IsInstanceAdmin, + Badges: user.Badges, + OnboardingCompleted: user.OnboardingCompletedAt != nil, + } + if payload.Badges == nil { + payload.Badges = []string{} + } + if user.AvatarFileID != nil { + payload.AvatarFileID = formatSnowflake(*user.AvatarFileID) + } + if user.BannerFileID != nil { + payload.BannerFileID = formatSnowflake(*user.BannerFileID) + } + if includePrivate { + payload.Locale = user.Locale + } + return payload +} + +type meOutput struct { + Body struct { + User profilePayload `json:"user"` + } +} + +type userOutput struct { + Body struct { + User profilePayload `json:"user"` + } +} + +type okOutput struct { + Body struct { + OK bool `json:"ok"` + } +} + +func newOKOutput() *okOutput { + output := &okOutput{} + output.Body.OK = true + return output +} + +type updateProfileInput struct { + Body struct { + DisplayName *string `json:"display_name,omitempty" maxLength:"32"` + Bio *string `json:"bio,omitempty" maxLength:"500"` + Status *string `json:"status,omitempty" enum:"online,idle,dnd,invisible"` + CustomStatus *string `json:"custom_status,omitempty" maxLength:"128"` + CustomStatusEmoji *string `json:"custom_status_emoji,omitempty" maxLength:"32"` + Locale *string `json:"locale,omitempty" enum:"ru,en"` + } +} + +type changePasswordInput struct { + Body struct { + CurrentPassword string `json:"current_password" minLength:"1"` + NewPassword string `json:"new_password" minLength:"1"` + } +} + +type onboardingInput struct { + Body struct { + DisplayName *string `json:"display_name,omitempty" maxLength:"32"` + Bio *string `json:"bio,omitempty" maxLength:"500"` + Locale *string `json:"locale,omitempty" enum:"ru,en"` + } +} + +type guildSummary struct { + ID string `json:"id"` + Name string `json:"name"` + IconFileID string `json:"icon_file_id,omitempty"` + OwnerID string `json:"owner_id"` + IsMain bool `json:"is_main"` + MemberCount int `json:"member_count"` + MyRoleIDs []string `json:"my_role_ids"` + MyPermissions []string `json:"my_permissions"` +} + +type guildListOutput struct { + Body struct { + Guilds []guildSummary `json:"guilds"` + } +} + +// registerUserRoutes описывает ручки профиля, онбординга и списка серверов. +func (s *Server) registerUserRoutes(api huma.API) { + security := []map[string][]string{{"sessionCookie": {}}, {"bearerAuth": {}}} + + huma.Register(api, huma.Operation{ + OperationID: "getCurrentUser", + Method: http.MethodGet, + Path: "/users/@me", + Summary: "Текущий пользователь", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*meOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + output := &meOutput{} + output.Body.User = profileFromUser(user, true) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "updateCurrentUser", + Method: http.MethodPatch, + Path: "/users/@me", + Summary: "Изменить профиль", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, input *updateProfileInput) (*meOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + params := store.UpdateUserParams{ + DisplayName: input.Body.DisplayName, + Bio: input.Body.Bio, + Status: input.Body.Status, + CustomStatus: input.Body.CustomStatus, + CustomStatusEmoji: input.Body.CustomStatusEmoji, + Locale: input.Body.Locale, + } + if input.Body.DisplayName != nil { + trimmed := strings.TrimSpace(*input.Body.DisplayName) + if trimmed == "" { + return nil, humaErrorStatus(http.StatusUnprocessableEntity, "validation.failed", "display name must not be empty") + } + params.DisplayName = &trimmed + } + updated, err := s.store.UpdateUser(ctx, user.ID, params) + if err != nil { + return nil, humaError(err) + } + // Профиль изменился — остальные клиенты получают событие (AGENT.md 8.3). + s.dispatchUserUpdate(updated) + output := &meOutput{} + output.Body.User = profileFromUser(updated, true) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "changePassword", + Method: http.MethodPost, + Path: "/users/@me/password", + Summary: "Сменить пароль (требует step-up)", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, input *changePasswordInput) (*okOutput, error) { + user, session, err := requireUser(ctx) + if err != nil { + return nil, err + } + if err := s.auth.ChangePassword(ctx, user.ID, session.ID, input.Body.CurrentPassword, input.Body.NewPassword); err != nil { + return nil, humaError(err) + } + return newOKOutput(), nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "completeOnboarding", + Method: http.MethodPost, + Path: "/users/@me/onboarding/complete", + Summary: "Завершить первичную настройку", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, input *onboardingInput) (*meOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + params := store.UpdateUserParams{ + Bio: input.Body.Bio, + Locale: input.Body.Locale, + } + if input.Body.DisplayName != nil { + if trimmed := strings.TrimSpace(*input.Body.DisplayName); trimmed != "" { + params.DisplayName = &trimmed + } + } + if _, err := s.store.UpdateUser(ctx, user.ID, params); err != nil { + return nil, humaError(err) + } + if err := s.store.MarkOnboardingCompleted(ctx, user.ID); err != nil { + return nil, humaError(err) + } + updated, err := s.store.GetUser(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + s.dispatchUserUpdate(updated) + output := &meOutput{} + output.Body.User = profileFromUser(updated, true) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "getUser", + Method: http.MethodGet, + Path: "/users/{user_id}", + Summary: "Публичный профиль пользователя", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, input *struct { + UserID string `path:"user_id"` + }, + ) (*userOutput, error) { + if _, _, err := requireUser(ctx); err != nil { + return nil, err + } + id, err := parseID("user_id", input.UserID) + if err != nil { + return nil, err + } + user, err := s.store.GetUser(ctx, id) + if err != nil { + return nil, humaError(err) + } + output := &userOutput{} + output.Body.User = profileFromUser(user, false) + return output, nil + }) + + huma.Register(api, huma.Operation{ + OperationID: "listMyGuilds", + Method: http.MethodGet, + Path: "/users/@me/guilds", + Summary: "Серверы текущего пользователя", + Tags: []string{"Users"}, + Security: security, + }, func(ctx context.Context, _ *struct{}) (*guildListOutput, error) { + user, _, err := requireUser(ctx) + if err != nil { + return nil, err + } + guilds, err := s.store.ListGuildsForUser(ctx, user.ID) + if err != nil { + return nil, humaError(err) + } + summaries := make([]guildSummary, 0, len(guilds)) + for _, guild := range guilds { + resolved, err := s.guildPermissions(ctx, guild.ID, user) + if err != nil { + return nil, err + } + if !resolved.Has(permissions.ViewGuild) { + continue + } + summary, err := s.guildSummary(ctx, guild, user.ID, resolved) + if err != nil { + return nil, err + } + summaries = append(summaries, summary) + } + output := &guildListOutput{} + output.Body.Guilds = summaries + return output, nil + }) +} + +// guildSummary собирает краткую карточку сервера для списка. +func (s *Server) guildSummary(ctx context.Context, guild store.Guild, userID uint64, resolved permissions.Resolved) (guildSummary, error) { + summary := guildSummary{ + ID: formatSnowflake(guild.ID), + Name: guild.Name, + OwnerID: formatSnowflake(guild.OwnerID), + IsMain: guild.IsMain, + MyRoleIDs: []string{}, + MyPermissions: permissions.Names(resolved.Guild), + } + if guild.IconFileID != nil { + summary.IconFileID = formatSnowflake(*guild.IconFileID) + } + roleIDs, err := s.store.MemberRoleIDs(ctx, guild.ID, userID) + if err != nil { + return guildSummary{}, humaError(err) + } + for _, roleID := range roleIDs { + summary.MyRoleIDs = append(summary.MyRoleIDs, formatSnowflake(roleID)) + } + count, err := s.store.CountGuildMembers(ctx, guild.ID) + if err != nil { + return guildSummary{}, humaError(err) + } + summary.MemberCount = count + return summary, nil +} + +// dispatchUserUpdate рассылает обновление профиля во все сессии пользователя. +func (s *Server) dispatchUserUpdate(user *store.User) { + if s.gateway == nil { + return + } + s.gateway.SendToUser(user.ID, "USER_UPDATE", map[string]any{ + "user": profileFromUser(user, true), + }) +} diff --git a/internal/server/auth.go b/internal/server/auth.go index 895d243..32612d3 100644 --- a/internal/server/auth.go +++ b/internal/server/auth.go @@ -7,12 +7,14 @@ import ( "github.com/go-chi/chi/v5" "glchat/internal/auth" + "glchat/internal/gateway" "glchat/internal/httpx" "glchat/internal/store" ) // sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-). -const sessionCookieName = "__Host-session" +// Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie. +const sessionCookieName = gateway.SessionCookieName const sessionCookiePath = "/" @@ -54,7 +56,6 @@ func (s *Server) registerAuthRoutes(router chi.Router) { router.Post("/auth/step-up", s.handleStepUp) router.Post("/auth/2fa/setup", s.handleSetupTOTP) router.Post("/auth/2fa/enable", s.handleEnableTOTP) - router.Get("/users/@me", s.handleGetMe) } type registerRequest struct { @@ -77,19 +78,22 @@ type currentUserPayload struct { IsInstanceAdmin bool `json:"is_instance_admin"` Badges []string `json:"badges"` Locale string `json:"locale"` + // OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2). + OnboardingCompleted bool `json:"onboarding_completed"` } func userPayload(user *store.User) currentUserPayload { payload := currentUserPayload{ - ID: formatSnowflake(user.ID), - Username: user.Username, - DisplayName: user.DisplayName, - Bio: user.Bio, - Status: user.Status, - CustomStatus: user.CustomStatus, - IsInstanceAdmin: user.IsInstanceAdmin, - Badges: user.Badges, - Locale: user.Locale, + ID: formatSnowflake(user.ID), + Username: user.Username, + DisplayName: user.DisplayName, + Bio: user.Bio, + Status: user.Status, + CustomStatus: user.CustomStatus, + IsInstanceAdmin: user.IsInstanceAdmin, + Badges: user.Badges, + Locale: user.Locale, + OnboardingCompleted: user.OnboardingCompletedAt != nil, } if payload.Badges == nil { payload.Badges = []string{} @@ -251,14 +255,6 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"sessions": payload}) } -func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) { - user, _, ok := s.authenticate(w, r) - if !ok { - return - } - writeJSON(w, map[string]any{"user": userPayload(user)}) -} - type totpEnableRequest struct { Code string `json:"code"` } diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go index a76646d..8eeff75 100644 --- a/internal/server/auth_test.go +++ b/internal/server/auth_test.go @@ -110,7 +110,15 @@ func TestOpenAPIDocumentsAuthEndpoints(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil { t.Fatalf("decode openapi: %v", err) } - for _, path := range []string{"/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", "/users/@me", "/auth/2fa/setup", "/meta"} { + paths := []string{ + "/auth/register", "/auth/login", "/auth/logout", "/auth/sessions", + "/users/@me", "/auth/2fa/setup", "/meta", + // Ручки Фазы 1, сгенерированные huma. + "/users/@me/guilds", "/guilds", "/guilds/{guild_id}", "/guilds/{guild_id}/channels", + "/guilds/{guild_id}/members", "/guilds/{guild_id}/roles", "/instance", + "/instance/settings", "/instance/guilds", "/instance/users", "/instance/audit", + } + for _, path := range paths { if _, ok := doc.Paths[path]; !ok { t.Errorf("openapi is missing %s", path) } diff --git a/internal/server/server.go b/internal/server/server.go index 3ff62cc..989abd7 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,8 @@ import ( "glchat/internal/gateway" "glchat/internal/httpx" "glchat/internal/meta" + "glchat/internal/permissions" + "glchat/internal/source" "glchat/internal/store" ) @@ -26,6 +28,9 @@ type Deps struct { Store *store.Store Auth *auth.Service Gateway *gateway.Service + // Permissions — общий с Gateway калькулятор прав (необязателен: если не + // передан, сервер создаёт собственный). + Permissions *permissions.Calculator } type Server struct { @@ -34,10 +39,12 @@ type Server struct { store *store.Store auth *auth.Service gateway *gateway.Service - logger *slog.Logger - http *http.Server - static *staticHandler - api huma.API + // perms — движок прав: ручки проверяют права теми же правилами, что Gateway. + perms *permissions.Calculator + logger *slog.Logger + http *http.Server + static *staticHandler + api huma.API } func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Server { @@ -50,12 +57,25 @@ func New(cfg config.Config, db *database.DB, logger *slog.Logger, deps Deps) *Se logger: logger, static: newStaticHandler(cfg.WebRoot), } + switch { + case deps.Permissions != nil: + s.perms = deps.Permissions + case deps.Store != nil: + s.perms = permissions.NewCalculator(source.New(deps.Store)) + } router := chi.NewRouter() router.Route("/api/v1", func(apiRouter chi.Router) { + // Сессия резолвится один раз на запрос: huma-ручки читают её из контекста. + apiRouter.Use(s.sessionContext) s.api = s.registerAPI(apiRouter) s.registerMetaRoutes(s.api) s.registerAuthRoutes(apiRouter) + if deps.Store != nil { + s.registerUserRoutes(s.api) + s.registerGuildRoutes(s.api) + s.registerInstanceRoutes(s.api) + } apiRouter.Get("/openapi.json", s.handleOpenAPI) }) s.registerRoutes(router) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c063469..d26ecf5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -17,6 +17,8 @@ import ( "glchat/internal/config" "glchat/internal/database" "glchat/internal/gateway" + "glchat/internal/permissions" + "glchat/internal/source" "glchat/internal/store" ) @@ -62,8 +64,11 @@ func newTestServer(t *testing.T) (*Server, *database.DB) { if err != nil { t.Fatalf("initialize authentication: %v", err) } - gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st), logger, cfg.AllowedOrigins()) - return New(cfg, db, logger, Deps{Store: st, Auth: authService, Gateway: gatewayService}), db + calculator := permissions.NewCalculator(source.New(st)) + gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins()) + return New(cfg, db, logger, Deps{ + Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator, + }), db } // TestGatewayRouteUpgrades проверяет связку: маршрут /gateway доступен через diff --git a/internal/source/permissions.go b/internal/source/permissions.go index 269c1cd..75023ff 100644 --- a/internal/source/permissions.go +++ b/internal/source/permissions.go @@ -41,6 +41,17 @@ func (a *Adapter) MemberRoleIDs(ctx context.Context, guildID, userID uint64) ([] return a.store.MemberRoleIDs(ctx, guildID, userID) } +// IsMember отвечает, состоит ли пользователь в сервере. +func (a *Adapter) IsMember(ctx context.Context, guildID, userID uint64) (bool, error) { + if _, err := a.store.GetGuildMember(ctx, guildID, userID); err != nil { + if errors.Is(err, store.ErrNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + func (a *Adapter) ChannelOverrides(ctx context.Context, channelID uint64) ([]permissions.OverrideData, error) { overrides, err := a.store.ListChannelOverrides(ctx, channelID) if err != nil { diff --git a/internal/store/guilds.go b/internal/store/guilds.go index 7276235..3c6ab4f 100644 --- a/internal/store/guilds.go +++ b/internal/store/guilds.go @@ -357,3 +357,103 @@ func prefixColumns(prefix, columns string) string { } return strings.Join(parts, ", ") } + +// GuildMemberProfile — участник сервера вместе с публичными полями профиля: +// нужен списку участников в клиенте (AGENT.md 8.2). +type GuildMemberProfile struct { + UserID uint64 + Username string + DisplayName string + Nickname string + AvatarFileID *uint64 + Status string + CustomStatus string + IsInstanceAdmin bool + JoinedAt time.Time + TimeoutUntil *time.Time + RoleIDs []uint64 +} + +// ListGuildMemberProfiles возвращает участников сервера с профилями и ролями. +func (s *Store) ListGuildMemberProfiles(ctx context.Context, guildID uint64) ([]GuildMemberProfile, error) { + rows, err := s.reader.QueryContext(ctx, ` + SELECT m.user_id, u.username, u.display_name, m.nickname, u.avatar_file_id, + u.status, u.custom_status, u.is_instance_admin, m.joined_at, m.timeout_until + FROM guild_members m + JOIN users u ON u.id = m.user_id + WHERE m.guild_id = ? AND u.deleted_at IS NULL + ORDER BY m.joined_at, m.user_id`, int64(guildID)) + if err != nil { + return nil, err + } + defer rows.Close() + + profiles := make([]GuildMemberProfile, 0, 16) + for rows.Next() { + var ( + profile GuildMemberProfile + nickname sql.NullString + avatarID sql.NullInt64 + isAdmin int + joinedAt string + timeoutUntil sql.NullString + ) + if err := rows.Scan(&profile.UserID, &profile.Username, &profile.DisplayName, &nickname, + &avatarID, &profile.Status, &profile.CustomStatus, &isAdmin, &joinedAt, &timeoutUntil); err != nil { + return nil, err + } + if nickname.Valid { + profile.Nickname = nickname.String + } + if avatarID.Valid { + value := uint64(avatarID.Int64) + profile.AvatarFileID = &value + } + profile.IsInstanceAdmin = isAdmin == 1 + profile.JoinedAt = parseTimestamp(joinedAt) + if timeoutUntil.Valid { + value := parseTimestamp(timeoutUntil.String) + profile.TimeoutUntil = &value + } + profiles = append(profiles, profile) + } + if err := rows.Err(); err != nil { + return nil, err + } + + // Роли участников читаем одним запросом и раскладываем по участникам. + roleRows, err := s.reader.QueryContext(ctx, ` + SELECT mr.user_id, mr.role_id + FROM member_roles mr + JOIN guild_members m ON m.guild_id = mr.guild_id AND m.user_id = mr.user_id + WHERE mr.guild_id = ? + ORDER BY mr.role_id`, int64(guildID)) + if err != nil { + return nil, err + } + defer roleRows.Close() + + index := make(map[uint64]int, len(profiles)) + for i, profile := range profiles { + index[profile.UserID] = i + } + for roleRows.Next() { + var userID, roleID uint64 + if err := roleRows.Scan(&userID, &roleID); err != nil { + return nil, err + } + if position, ok := index[userID]; ok { + profiles[position].RoleIDs = append(profiles[position].RoleIDs, roleID) + } + } + return profiles, roleRows.Err() +} + +// CountGuildMemberships возвращает число серверов, которыми владеет пользователь +// и в которых состоит: используется для лимитов (AGENT.md 6.5). +func (s *Store) CountGuildMemberships(ctx context.Context, userID uint64) (int, error) { + var count int + err := s.reader.QueryRowContext(ctx, + `SELECT COUNT(*) FROM guild_members WHERE user_id = ?`, int64(userID)).Scan(&count) + return count, err +} diff --git a/internal/store/instance.go b/internal/store/instance.go index deafbeb..8223f06 100644 --- a/internal/store/instance.go +++ b/internal/store/instance.go @@ -127,6 +127,10 @@ func (s *Store) ListAuditLog(ctx context.Context, guildID uint64, limit int) ([] if err != nil { return nil, err } + return scanAuditEntries(rows, limit) +} + +func scanAuditEntries(rows *sql.Rows, limit int) ([]AuditEntry, error) { defer rows.Close() entries := make([]AuditEntry, 0, limit) @@ -191,3 +195,19 @@ func parseUint(value string) (uint64, bool) { } return result, true } + +// ListInstanceAudit отдаёт общий журнал инстанса: действия администраторов +// инстанса (в том числе внутри серверов) и записи без привязки к серверу, +// которые переживают удаление сервера (AGENT.md 7.18). +func (s *Store) ListInstanceAudit(ctx context.Context, limit int) ([]AuditEntry, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := s.reader.QueryContext(ctx, ` + SELECT id, guild_id, actor_id, actor_instance_admin, action, target_type, target_id, reason, changes_json, created_at + FROM audit_log WHERE actor_instance_admin = 1 OR guild_id IS NULL ORDER BY id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + return scanAuditEntries(rows, limit) +} diff --git a/internal/store/users.go b/internal/store/users.go index ce8207f..985673e 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -29,6 +29,8 @@ type User struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time + // OnboardingCompletedAt заполняется после первичной настройки (AGENT.md 7.2). + OnboardingCompletedAt *time.Time } // CreateUserParams — данные новой учётной записи: шифрование и blind index @@ -45,7 +47,8 @@ type CreateUserParams struct { const userColumns = `id, username, display_name, email_enc, password_hash, avatar_file_id, banner_file_id, bio, status, custom_status, custom_status_emoji, flags, - is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at` + is_instance_admin, badges_json, locale, created_at, updated_at, deleted_at, + onboarding_completed_at` func (s *Store) CreateUser(ctx context.Context, params CreateUserParams) (*User, error) { if params.ID == 0 { @@ -218,6 +221,47 @@ func (s *Store) SetUserBadges(ctx context.Context, id uint64, badges []string) e return nil } +// MarkOnboardingCompleted фиксирует завершение первичной настройки (AGENT.md 7.2). +func (s *Store) MarkOnboardingCompleted(ctx context.Context, id uint64) error { + result, err := s.writer.ExecContext(ctx, + `UPDATE users SET onboarding_completed_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL`, + s.Now(), s.Now(), int64(id)) + if err != nil { + return err + } + if affected, err := result.RowsAffected(); err == nil && affected == 0 { + return ErrNotFound + } + return nil +} + +// ListUsers отдаёт страницу пользователей для админ-панели инстанса (AGENT.md 6.5). +func (s *Store) ListUsers(ctx context.Context, limit, offset int) ([]User, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + rows, err := s.reader.QueryContext(ctx, + `SELECT `+userColumns+` FROM users WHERE deleted_at IS NULL ORDER BY id LIMIT ? OFFSET ?`, + limit, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + users := make([]User, 0, limit) + for rows.Next() { + user, err := scanUser(rows) + if err != nil { + return nil, err + } + users = append(users, *user) + } + return users, rows.Err() +} + func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { var ( user User @@ -228,12 +272,14 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { createdAt string updatedAt string deletedAt sql.NullString + onboardingAt sql.NullString emailEncrypted string ) err := scanner.Scan( &user.ID, &user.Username, &user.DisplayName, &emailEncrypted, &user.PasswordHash, &avatarID, &bannerID, &user.Bio, &user.Status, &user.CustomStatus, &user.CustomStatusEmoji, &user.Flags, &isAdmin, &badges, &user.Locale, &createdAt, &updatedAt, &deletedAt, + &onboardingAt, ) if err != nil { return nil, mapError(err) @@ -256,6 +302,10 @@ func scanUser(scanner interface{ Scan(...any) error }) (*User, error) { value := parseTimestamp(deletedAt.String) user.DeletedAt = &value } + if onboardingAt.Valid { + value := parseTimestamp(onboardingAt.String) + user.OnboardingCompletedAt = &value + } return &user, nil } diff --git a/web/src/App.tsx b/web/src/App.tsx index 005e8d3..0ca507b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,125 +1,100 @@ -import { useQuery } from '@tanstack/react-query'; -import { useTranslation } from 'react-i18next'; +import { lazy, Suspense } from 'react'; +import { createBrowserRouter, Navigate, RouterProvider } from 'react-router'; -import { fetchReadiness, readyQueryKey } from '@/api/health'; -import { fetchMeta, metaQueryKey } from '@/api/meta'; -import { Badge, Button, Card } from '@/components/ui/primitives'; -import { StatBytes, StatRow } from '@/components/ui/StatRow'; -import { errorCode } from '@/lib/format'; -import { supportedLanguages, type SupportedLanguage } from '@/i18n'; -import { useUiStore } from '@/stores/ui'; +import { AuthGuard } from '@/components/AuthGuard'; +import { LoadingNotice } from '@/components/ui/ErrorNotice'; +import { useCurrentUser } from '@/lib/hooks'; +import '@/i18n'; -export function App() { - const { t, i18n } = useTranslation(); - const theme = useUiStore((state) => state.theme); - const toggleTheme = useUiStore((state) => state.toggleTheme); - const locale = i18n.resolvedLanguage ?? 'ru'; +const LoginPage = lazy(() => import('@/pages/LoginPage')); +const RegisterPage = lazy(() => import('@/pages/RegisterPage')); +const OnboardingPage = lazy(() => import('@/pages/OnboardingPage')); +const StatusPage = lazy(() => import('@/pages/StatusPage')); +const AppLayout = lazy(() => import('@/pages/app/AppLayout')); +const GuildView = lazy(() => import('@/pages/app/GuildView')); +const NoGuildView = lazy(() => import('@/pages/app/NoGuildView')); +const SettingsLayout = lazy(() => import('@/pages/settings/SettingsLayout')); +const ProfileSettingsPage = lazy(() => import('@/pages/settings/ProfileSettingsPage')); +const SecuritySettingsPage = lazy(() => import('@/pages/settings/SecuritySettingsPage')); +const AppearanceSettingsPage = lazy(() => import('@/pages/settings/AppearanceSettingsPage')); +const InstanceSettingsPage = lazy(() => import('@/pages/settings/InstanceSettingsPage')); - const meta = useQuery({ - queryKey: metaQueryKey, - queryFn: ({ signal }) => fetchMeta(signal), - retry: 1, - }); - - const readiness = useQuery({ - queryKey: readyQueryKey, - queryFn: ({ signal }) => fetchReadiness(signal), - retry: 1, - refetchInterval: 30_000, - }); - - const online = readiness.isSuccess || meta.isSuccess; - const schemaReady = readiness.data?.checks['database'] === 'ok'; - - const errorMessage = (error: unknown): string => { - const code = errorCode(error); - const key = `errors.${code}`; - const translated = t(key); - return translated === key ? t('errors.unknown', { code }) : translated; - }; - - const changeLanguage = (language: SupportedLanguage): void => { - window.localStorage.setItem('glchat.language', language); - void i18n.changeLanguage(language); - }; +/** `/` — редирект по состоянию сессии. */ +function IndexRedirect() { + const currentUser = useCurrentUser(); + if (currentUser.isPending) { + return ( +
{t('app.tagline')}
-{t('status.description')}
- - {meta.isPending ? ( -- {t('status.connecting')} -
- ) : null} - - {meta.isError ? ( -{errorMessage(meta.error)}
- -{describe(error)}
+ {onRetry === undefined ? null : ( + + )} ++ {label ?? t('common.loading')} +
+ ); +} diff --git a/web/src/components/ui/Field.tsx b/web/src/components/ui/Field.tsx new file mode 100644 index 0000000..6035b95 --- /dev/null +++ b/web/src/components/ui/Field.tsx @@ -0,0 +1,128 @@ +import { + useId, + type InputHTMLAttributes, + type ReactNode, + type TextareaHTMLAttributes, +} from 'react'; + +interface FieldShellProps { + label: string; + hint?: string | undefined; + error?: string | null | undefined; + children: (ids: { id: string; describedBy: string | undefined }) => ReactNode; +} + +/** Общая обёртка поля: label, подсказка и сообщение об ошибке. */ +function FieldShell({ label, hint, error, children }: FieldShellProps) { + const id = useId(); + const hintId = `${id}-hint`; + const errorId = `${id}-error`; + const describedBy = + [hint === undefined ? null : hintId, error === null || error === undefined ? null : errorId] + .filter((value): value is string => value !== null) + .join(' ') || undefined; + + return ( ++ {hint} +
+ )} + {error === null || error === undefined ? null : ( ++ {error} +
+ )} +`), а неизвестные коды
+ * показывает через `errors.unknown` — единый механизм для всего клиента.
+ */
+export function useApiErrorMessage(): (error: unknown) => string {
+ const { t } = useTranslation();
+ return (error: unknown): string => {
+ const code = errorCode(error);
+ const key = `errors.${code}`;
+ const translated = t(key);
+ return translated === key ? t('errors.unknown', { code }) : translated;
+ };
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 956cd15..2d5242c 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,21 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { QueryClientProvider } from '@tanstack/react-query';
import { App } from '@/App';
+import { getQueryClient } from '@/lib/queryClient';
import { applyTheme, useUiStore } from '@/stores/ui';
import '@/i18n';
import '@/index.css';
-const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- staleTime: 30_000,
- refetchOnWindowFocus: false,
- },
- },
-});
-
applyTheme(useUiStore.getState().theme);
const container = document.getElementById('root');
@@ -25,7 +17,7 @@ if (!container) {
createRoot(container).render(
-
+
,
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..a4879c9
--- /dev/null
+++ b/web/src/pages/LoginPage.tsx
@@ -0,0 +1,125 @@
+import { useState, type FormEvent } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link, useLocation, useNavigate } from 'react-router';
+
+import { login } from '@/api/auth';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+import { getQueryClient } from '@/lib/queryClient';
+import { useInstance } from '@/lib/hooks';
+import { errorCode } from '@/lib/format';
+
+interface LocationState {
+ from?: string;
+}
+
+/** Форма входа: поле кода 2FA появляется при ответе `auth.2fa_required`. */
+export default function LoginPage() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const instance = useInstance();
+
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [totpCode, setTotpCode] = useState('');
+ const [needsTotp, setNeedsTotp] = useState(false);
+
+ const from = (location.state as LocationState | null)?.from ?? '/app';
+
+ const submit = useMutation({
+ mutationFn: () =>
+ login(needsTotp ? { email, password, totp_code: totpCode } : { email, password }),
+ onSuccess: async () => {
+ // Профиль перечитываем заново: cookie уже выставлена сервером.
+ await getQueryClient().invalidateQueries();
+ void navigate(from === '/login' ? '/app' : from, { replace: true });
+ },
+ onError: (error: unknown) => {
+ if (errorCode(error) === 'auth.2fa_required') {
+ setNeedsTotp(true);
+ }
+ },
+ });
+
+ const onSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ submit.mutate();
+ };
+
+ const registrationEnabled = instance.data?.registration_enabled === true;
+
+ return (
+
+
+ {t('auth.login.title')}
+ {t('auth.login.subtitle')}
+
+
+
+
+
+
+ {t('auth.login.noAccount')}{' '}
+ {registrationEnabled ? (
+
+ {t('auth.login.registerLink')}
+
+ ) : (
+ {t('auth.login.registrationDisabled')}
+ )}
+
+ {t('auth.login.forgot')}
+
+
+
+
+ );
+}
diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx
new file mode 100644
index 0000000..007a13d
--- /dev/null
+++ b/web/src/pages/OnboardingPage.tsx
@@ -0,0 +1,150 @@
+import { useEffect, useState, type FormEvent } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
+
+import { completeOnboarding, currentUserQueryKey } from '@/api/users';
+import type { OnboardingInput } from '@/api/users';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+import { supportedLanguages, type SupportedLanguage } from '@/i18n';
+import { useCurrentUser } from '@/lib/hooks';
+import { useSessionStore } from '@/stores/session';
+import { useUiStore, type Theme } from '@/stores/ui';
+
+/** Первичная настройка: имя, «о себе», язык и тема, затем POST onboarding/complete. */
+export default function OnboardingPage() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const currentUser = useCurrentUser();
+ const theme = useUiStore((state) => state.theme);
+ const setTheme = useUiStore((state) => state.setTheme);
+ const sessionUser = useSessionStore((state) => state.user);
+
+ const [displayName, setDisplayName] = useState('');
+ const [bio, setBio] = useState('');
+ const [locale, setLocale] = useState(
+ i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
+ );
+ const [touched, setTouched] = useState(false);
+
+ const user = currentUser.data;
+ const suggestedName = user?.display_name ?? sessionUser?.display_name ?? user?.username ?? '';
+
+ useEffect(() => {
+ if (suggestedName !== '' && !touched) {
+ setDisplayName((current) => (current === '' ? suggestedName : current));
+ }
+ }, [suggestedName, touched]);
+
+ useEffect(() => {
+ const preferred = user?.locale;
+ if (preferred === 'en' || preferred === 'ru') {
+ setLocale(preferred);
+ }
+ }, [user?.locale]);
+
+ const complete = useMutation({
+ mutationFn: (input: OnboardingInput) => completeOnboarding(input),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(currentUserQueryKey, updated);
+ useSessionStore.getState().patchUser(updated);
+ void navigate('/app', { replace: true });
+ },
+ });
+
+ const changeLanguage = (language: SupportedLanguage): void => {
+ setLocale(language);
+ window.localStorage.setItem('glchat.language', language);
+ void i18n.changeLanguage(language);
+ };
+
+ const changeTheme = (next: Theme): void => {
+ setTheme(next);
+ };
+
+ const onSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ setTouched(true);
+ const input: OnboardingInput = { locale };
+ const trimmedName = displayName.trim();
+ const trimmedBio = bio.trim();
+ if (trimmedName !== '') {
+ input.display_name = trimmedName;
+ }
+ if (trimmedBio !== '') {
+ input.bio = trimmedBio;
+ }
+ complete.mutate(input);
+ };
+
+ const unknownUser = currentUser.isError && !complete.isSuccess;
+
+ return (
+
+
+
+ {suggestedName === ''
+ ? t('onboarding.title')
+ : t('onboarding.greeting', { name: suggestedName })}
+
+ {t('onboarding.subtitle')}
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..d75e4e0
--- /dev/null
+++ b/web/src/pages/RegisterPage.tsx
@@ -0,0 +1,198 @@
+import { useState, type FormEvent } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link, useNavigate } from 'react-router';
+
+import { register } from '@/api/auth';
+import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
+import { Field, SelectField } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+import { supportedLanguages, type SupportedLanguage } from '@/i18n';
+import { useInstance } from '@/lib/hooks';
+
+const usernamePattern = /^[A-Za-z0-9_-]{3,32}$/u;
+const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/u;
+
+interface FormErrors {
+ username?: string;
+ display_name?: string;
+ email?: string;
+ password?: string;
+}
+
+/** Регистрация: клиентские проверки, требования к паролю и учёт флага инстанса. */
+export default function RegisterPage() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const instance = useInstance();
+
+ const [username, setUsername] = useState('');
+ const [displayName, setDisplayName] = useState('');
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [locale, setLocale] = useState(
+ i18n.resolvedLanguage === 'en' ? 'en' : 'ru',
+ );
+ const [errors, setErrors] = useState({});
+
+ const submit = useMutation({
+ mutationFn: () =>
+ register({
+ username,
+ display_name: displayName,
+ email,
+ password,
+ locale,
+ }),
+ onSuccess: () => {
+ void navigate('/onboarding', { replace: true });
+ },
+ });
+
+ const validate = (): FormErrors => {
+ const next: FormErrors = {};
+ if (!usernamePattern.test(username)) {
+ next.username = t('auth.validation.usernameFormat');
+ }
+ if (displayName.trim().length === 0) {
+ next.display_name = t('auth.validation.displayNameRequired');
+ }
+ if (!emailPattern.test(email)) {
+ next.email = t('auth.validation.emailInvalid');
+ }
+ if (password.length < 8) {
+ next.password = t('auth.validation.passwordShort');
+ } else if (!/\p{L}/u.test(password)) {
+ next.password = t('auth.validation.passwordLetter');
+ } else if (!/\d/u.test(password)) {
+ next.password = t('auth.validation.passwordDigit');
+ }
+ return next;
+ };
+
+ const onSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ const next = validate();
+ setErrors(next);
+ if (Object.keys(next).length > 0) {
+ return;
+ }
+ submit.mutate();
+ };
+
+ if (instance.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (instance.isError) {
+ return (
+
+ void instance.refetch()} />
+
+ );
+ }
+
+ if (!instance.data.registration_enabled) {
+ return (
+
+
+ {t('auth.register.disabledTitle')}
+ {t('auth.register.disabledBody')}
+
+
+ {t('auth.register.loginLink')}
+
+
+ );
+ }
+
+ return (
+
+
+ {t('auth.register.title')}
+
+ {t('auth.register.subtitle', { instance: instance.data.name })}
+
+
+
+
+
+
+
+ {t('auth.register.haveAccount')}{' '}
+
+ {t('auth.register.loginLink')}
+
+
+
+
+ );
+}
diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx
new file mode 100644
index 0000000..6aef53c
--- /dev/null
+++ b/web/src/pages/StatusPage.tsx
@@ -0,0 +1,130 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router';
+
+import { fetchReadiness, readyQueryKey } from '@/api/health';
+import { fetchMeta, metaQueryKey } from '@/api/meta';
+import { Badge, Button, Card } from '@/components/ui/primitives';
+import { StatBytes, StatRow } from '@/components/ui/StatRow';
+import { supportedLanguages, type SupportedLanguage } from '@/i18n';
+import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
+import { useUiStore } from '@/stores/ui';
+
+/** Публичная страница состояния инстанса: мета, healthz/readyz, тема и язык. */
+export default function StatusPage() {
+ const { t, i18n } = useTranslation();
+ const describeError = useApiErrorMessage();
+ const theme = useUiStore((state) => state.theme);
+ const toggleTheme = useUiStore((state) => state.toggleTheme);
+ const locale = i18n.resolvedLanguage ?? 'ru';
+
+ const meta = useQuery({
+ queryKey: metaQueryKey,
+ queryFn: ({ signal }) => fetchMeta(signal),
+ retry: 1,
+ });
+
+ const readiness = useQuery({
+ queryKey: readyQueryKey,
+ queryFn: ({ signal }) => fetchReadiness(signal),
+ retry: 1,
+ refetchInterval: 30_000,
+ });
+
+ const online = readiness.isSuccess || meta.isSuccess;
+ const schemaReady = readiness.data?.checks['database'] === 'ok';
+
+ const changeLanguage = (language: SupportedLanguage): void => {
+ window.localStorage.setItem('glchat.language', language);
+ void i18n.changeLanguage(language);
+ };
+
+ return (
+
+
+
+ {t('app.name')}
+ {t('app.tagline')}
+
+
+
+
+ {t(online ? 'health.online' : 'health.offline')}
+
+
+
+
+
+
+
+ {t('status.title')}
+ {t('status.description')}
+
+ {meta.isPending ? (
+
+ {t('status.connecting')}
+
+ ) : null}
+
+ {meta.isError ? (
+
+ {describeError(meta.error)}
+
+
+ ) : null}
+
+ {meta.data ? (
+
+
+
+
+
+ }
+ />
+
+ ) : null}
+
+
+
+
+ {t('auth.login.title')}
+
+
+ {t('auth.register.title')}
+
+
+
+
+
+ );
+}
diff --git a/web/src/pages/app/AppLayout.tsx b/web/src/pages/app/AppLayout.tsx
new file mode 100644
index 0000000..0194deb
--- /dev/null
+++ b/web/src/pages/app/AppLayout.tsx
@@ -0,0 +1,193 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Outlet, useLocation, useNavigate } from 'react-router';
+
+import { createGuild, fetchGuild, guildQueryKey } from '@/api/guilds';
+import { fetchMyGuilds, myGuildsQueryKey } from '@/api/users';
+import { instanceQueryKey } from '@/api/instance';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field } from '@/components/ui/Field';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/primitives';
+import { useInstance } from '@/lib/hooks';
+import { useSessionStore } from '@/stores/session';
+import { ChannelSidebar } from '@/pages/app/ChannelSidebar';
+import { GuildRail } from '@/pages/app/GuildRail';
+import { UserPanel } from '@/pages/app/UserPanel';
+import { errorCode } from '@/lib/format';
+
+/**
+ * Оболочка приложения: рейка серверов, сайдбар комнат, контент и панель
+ * пользователя. Данные серверов приходят из снапшота шлюза, а если шлюз ещё
+ * не подключился — подстраховываемся REST-запросом `GET /users/@me/guilds`.
+ */
+export default function AppLayout() {
+ const { t } = useTranslation();
+ const location = useLocation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const instance = useInstance();
+
+ const guilds = useSessionStore((state) => state.guilds);
+ const selectedGuildId = useSessionStore((state) => state.selectedGuildId);
+ const selectChannel = useSessionStore((state) => state.selectChannel);
+ const upsertGuild = useSessionStore((state) => state.upsertGuild);
+
+ const [createOpen, setCreateOpen] = useState(false);
+ const [guildName, setGuildName] = useState('');
+
+ const segments = location.pathname.split('/').filter((segment) => segment !== '');
+ const guildId = segments[1] === undefined ? null : decodeURIComponent(segments[1]);
+ const channelId = segments[2] === undefined ? null : decodeURIComponent(segments[2]);
+
+ // URL — источник правды для выбранного сервера и комнаты.
+ useEffect(() => {
+ if (guildId === selectedGuildId) {
+ return;
+ }
+ selectChannel(guildId, channelId);
+ }, [guildId, channelId, selectedGuildId, selectChannel]);
+
+ const myGuilds = useQuery({
+ queryKey: myGuildsQueryKey,
+ queryFn: ({ signal }) => fetchMyGuilds(signal),
+ retry: 0,
+ });
+
+ // Список из снапшота шлюза; если он пуст — берём REST-ответ.
+ useEffect(() => {
+ if (guilds.length > 0 || myGuilds.data === undefined) {
+ return;
+ }
+ for (const guild of myGuilds.data) {
+ upsertGuild(guild);
+ }
+ }, [guilds.length, myGuilds.data, upsertGuild]);
+
+ const guildInSnapshot = guildId !== null && guilds.some((guild) => guild.id === guildId);
+ const fallbackGuild = useQuery({
+ queryKey: guildQueryKey(guildId ?? ''),
+ queryFn: ({ signal }) => fetchGuild(guildId ?? '', signal),
+ enabled: guildId !== null && !guildInSnapshot,
+ retry: 0,
+ });
+ const hydrated = useRef(null);
+
+ // Сервер есть в URL, но отсутствует в снапшоте (например, после RESUME).
+ useEffect(() => {
+ const guild = fallbackGuild.data;
+ if (guild === undefined || hydrated.current === guild.id) {
+ return;
+ }
+ hydrated.current = guild.id;
+ upsertGuild({
+ id: guild.id,
+ name: guild.name,
+ owner_id: guild.owner_id,
+ is_main: guild.is_main,
+ member_count: guild.member_count,
+ my_role_ids: guild.my_role_ids,
+ my_permissions: guild.my_permissions,
+ ...(guild.icon_file_id === undefined ? {} : { icon_file_id: guild.icon_file_id }),
+ });
+ }, [fallbackGuild.data, upsertGuild]);
+
+ const create = useMutation({
+ mutationFn: (name: string) => createGuild(name),
+ onSuccess: (guild) => {
+ setCreateOpen(false);
+ setGuildName('');
+ upsertGuild(guild);
+ void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
+ void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
+ void navigate(`/app/${guild.id}`);
+ },
+ });
+
+ const closeCreate = useCallback(() => {
+ setCreateOpen(false);
+ }, []);
+
+ const guildList = useMemo(
+ () => guilds.map((guild) => ({ id: guild.id, name: guild.name })),
+ [guilds],
+ );
+
+ const canCreate = instance.data?.allow_guild_creation !== false;
+ const limitReached =
+ instance.data !== undefined && guilds.length >= instance.data.max_guilds_per_user;
+
+ return (
+
+
+ {t('app.skipToContent')}
+
+
+ setCreateOpen(true)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+
+ );
+}
diff --git a/web/src/pages/app/ChannelSidebar.tsx b/web/src/pages/app/ChannelSidebar.tsx
new file mode 100644
index 0000000..77a72b7
--- /dev/null
+++ b/web/src/pages/app/ChannelSidebar.tsx
@@ -0,0 +1,275 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { NavLink, useNavigate } from 'react-router';
+
+import { createChannel, fetchChannels, guildChannelsQueryKey, leaveGuild } from '@/api/guilds';
+import { myGuildsQueryKey } from '@/api/users';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field, SelectField } from '@/components/ui/Field';
+import { Modal } from '@/components/ui/Modal';
+import { Button } from '@/components/ui/primitives';
+import { canManageGuild } from '@/lib/identity';
+import { useCurrentUser } from '@/lib/hooks';
+import { useSessionStore } from '@/stores/session';
+import type { Channel } from '@/api/types';
+
+interface ChannelSidebarProps {
+ guildId: string | null;
+ channelId: string | null;
+}
+
+interface ChannelGroup {
+ category: Channel | null;
+ channels: Channel[];
+}
+
+/** Группирует комнаты по категориям, сохраняя порядок `position`. */
+export function groupChannels(channels: Channel[]): ChannelGroup[] {
+ const visible = channels
+ .filter((channel) => channel.can_view !== false && channel.type !== 'category')
+ .slice()
+ .sort((left, right) => left.position - right.position);
+ const categories = channels
+ .filter((channel) => channel.type === 'category')
+ .slice()
+ .sort((left, right) => left.position - right.position);
+
+ const groups: ChannelGroup[] = [];
+ const uncategorized = visible.filter((channel) => channel.parent_id === undefined);
+ if (uncategorized.length > 0) {
+ groups.push({ category: null, channels: uncategorized });
+ }
+ for (const category of categories) {
+ const children = visible.filter((channel) => channel.parent_id === category.id);
+ if (children.length > 0) {
+ groups.push({ category, channels: children });
+ }
+ }
+ return groups;
+}
+
+/** Сайдбар комнат выбранного сервера: категории, текстовые и голосовые комнаты. */
+export function ChannelSidebar({ guildId, channelId }: ChannelSidebarProps) {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const currentUser = useCurrentUser();
+
+ const guild = useSessionStore(
+ useCallback((state) => state.guilds.find((item) => item.id === guildId) ?? null, [guildId]),
+ );
+ const setGuildChannels = useSessionStore((state) => state.setGuildChannels);
+ const upsertChannel = useSessionStore((state) => state.upsertChannel);
+ const removeGuild = useSessionStore((state) => state.removeGuild);
+
+ const [createOpen, setCreateOpen] = useState(false);
+ const [channelName, setChannelName] = useState('');
+ const [channelType, setChannelType] = useState<'text' | 'voice'>('text');
+ const [parentId, setParentId] = useState('');
+
+ const channels = guild?.channels ?? [];
+ const needsChannels = guildId !== null && guild !== null && channels.length === 0;
+
+ const remoteChannels = useQuery({
+ queryKey: guildChannelsQueryKey(guildId ?? ''),
+ queryFn: ({ signal }) => fetchChannels(guildId ?? '', signal),
+ enabled: needsChannels,
+ retry: 0,
+ });
+
+ useEffect(() => {
+ if (guildId === null || remoteChannels.data === undefined) {
+ return;
+ }
+ setGuildChannels(guildId, remoteChannels.data);
+ }, [guildId, remoteChannels.data, setGuildChannels]);
+
+ const create = useMutation({
+ mutationFn: () =>
+ createChannel(guildId ?? '', {
+ name: channelName.trim(),
+ type: channelType,
+ ...(parentId === '' ? {} : { parent_id: parentId }),
+ }),
+ onSuccess: (channel) => {
+ if (guildId !== null) {
+ upsertChannel(guildId, channel);
+ }
+ setCreateOpen(false);
+ setChannelName('');
+ void queryClient.invalidateQueries({ queryKey: guildChannelsQueryKey(guildId ?? '') });
+ void navigate(`/app/${guildId ?? ''}/${channel.id}`);
+ },
+ });
+
+ const leave = useMutation({
+ mutationFn: () => leaveGuild(guildId ?? ''),
+ onSuccess: () => {
+ if (guildId !== null) {
+ removeGuild(guildId);
+ }
+ void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
+ void navigate('/app/empty', { replace: true });
+ },
+ });
+
+ const groups = useMemo(() => groupChannels(channels), [channels]);
+ const categories = channels.filter((channel) => channel.type === 'category');
+ const canManage =
+ guild !== null && canManageGuild(guild.my_permissions, guild.owner_id, currentUser.data?.id);
+
+ if (guildId === null) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/app/GuildRail.tsx b/web/src/pages/app/GuildRail.tsx
new file mode 100644
index 0000000..935c141
--- /dev/null
+++ b/web/src/pages/app/GuildRail.tsx
@@ -0,0 +1,58 @@
+import { useTranslation } from 'react-i18next';
+import { NavLink } from 'react-router';
+
+import { Avatar } from '@/components/ui/Avatar';
+
+interface GuildRailProps {
+ guilds: { id: string; name: string }[];
+ selectedGuildId: string | null;
+ canCreate: boolean;
+ onCreate: () => void;
+}
+
+/** Левая рейка: иконки серверов-инициалов и кнопка создания сервера. */
+export function GuildRail({ guilds, selectedGuildId, canCreate, onCreate }: GuildRailProps) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/app/GuildView.tsx b/web/src/pages/app/GuildView.tsx
new file mode 100644
index 0000000..5fcd928
--- /dev/null
+++ b/web/src/pages/app/GuildView.tsx
@@ -0,0 +1,59 @@
+import { useTranslation } from 'react-i18next';
+import { useParams } from 'react-router';
+
+import { Card } from '@/components/ui/primitives';
+import { useSessionStore } from '@/stores/session';
+
+/**
+ * Область контента комнаты. В Фазе 1 — заглушка: сообщения, голос и видео
+ * появятся в следующих фазах, но маршрутизация и выбор комнаты уже работают.
+ */
+export default function GuildView() {
+ const { t } = useTranslation();
+ const params = useParams<{ guildId?: string; channelId?: string }>();
+ const guild = useSessionStore((state) =>
+ params.guildId === undefined
+ ? null
+ : (state.guilds.find((item) => item.id === params.guildId) ?? null),
+ );
+ const channel = guild?.channels.find((item) => item.id === params.channelId) ?? null;
+
+ if (params.channelId === undefined || channel === null) {
+ return (
+
+
+ {t('channels.noSelection.title')}
+ {t('channels.noSelection.body')}
+
+
+ );
+ }
+
+ const isVoice = channel.type === 'voice';
+
+ return (
+
+
+
+ {channel.name}
+ {channel.slowmode_seconds === undefined || channel.slowmode_seconds === 0 ? null : (
+ slowmode {channel.slowmode_seconds}s
+ )}
+
+
+
+
+ {t(isVoice ? 'channels.voicePlaceholder.title' : 'channels.chatPlaceholder.title')}
+
+
+ {t(isVoice ? 'channels.voicePlaceholder.body' : 'channels.chatPlaceholder.body', {
+ name: channel.name,
+ })}
+
+
+
+
+ );
+}
diff --git a/web/src/pages/app/NoGuildView.tsx b/web/src/pages/app/NoGuildView.tsx
new file mode 100644
index 0000000..165996a
--- /dev/null
+++ b/web/src/pages/app/NoGuildView.tsx
@@ -0,0 +1,72 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import { joinGuild } from '@/api/guilds';
+import { instanceQueryKey } from '@/api/instance';
+import { myGuildsQueryKey } from '@/api/users';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Button, Card } from '@/components/ui/primitives';
+import { useInstance } from '@/lib/hooks';
+import { useSessionStore } from '@/stores/session';
+
+/**
+ * Экран пустого списка серверов: присоединиться к главному серверу инстанса
+ * (id берём из `GET /instance`) или создать свой (кнопка есть в рейке).
+ */
+export default function NoGuildView() {
+ const { t } = useTranslation();
+ const queryClient = useQueryClient();
+ const instance = useInstance();
+ const guilds = useSessionStore((state) => state.guilds);
+
+ const mainGuildId = instance.data?.main_guild_id;
+ const alreadyMember =
+ mainGuildId !== undefined && guilds.some((guild) => guild.id === mainGuildId);
+
+ const join = useMutation({
+ mutationFn: (guildId: string) => joinGuild(guildId),
+ onSuccess: () => {
+ // Список серверов обновится снапшотом шлюза; REST — подстраховка.
+ void queryClient.invalidateQueries({ queryKey: myGuildsQueryKey });
+ void queryClient.invalidateQueries({ queryKey: instanceQueryKey });
+ },
+ });
+
+ return (
+
+
+ {t('guilds.empty.title')}
+ {t('guilds.empty.body')}
+
+ {instance.isPending ? (
+
+ {t('common.loading')}
+
+ ) : null}
+
+ {mainGuildId !== undefined && !alreadyMember ? (
+
+ ) : null}
+
+ {mainGuildId === undefined && !instance.isPending ? (
+ {t('guilds.empty.noMain')}
+ ) : null}
+
+ {join.isError ? : null}
+ {instance.isError ? (
+ void instance.refetch()}
+ />
+ ) : null}
+
+
+ );
+}
diff --git a/web/src/pages/app/UserPanel.tsx b/web/src/pages/app/UserPanel.tsx
new file mode 100644
index 0000000..14dc34e
--- /dev/null
+++ b/web/src/pages/app/UserPanel.tsx
@@ -0,0 +1,105 @@
+import { useCallback } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Link, useNavigate } from 'react-router';
+
+import { logout } from '@/api/auth';
+import { Avatar } from '@/components/ui/Avatar';
+import { useCurrentUser } from '@/lib/hooks';
+import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
+import { useGatewayStore } from '@/stores/gateway';
+import { useSessionStore } from '@/stores/session';
+import { userStatuses, type UserStatus } from '@/api/types';
+
+/** Панель пользователя внизу: аватар, имя, статус, настройки и выход. */
+export function UserPanel() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const currentUser = useCurrentUser();
+ const describeError = useApiErrorMessage();
+ const sessionUser = useSessionStore((state) => state.user);
+ const disconnect = useGatewayStore((state) => state.disconnect);
+
+ const name = sessionUser?.display_name ?? currentUser.data?.display_name ?? '';
+ const username = sessionUser?.username ?? currentUser.data?.username ?? '';
+ const status: UserStatus = sessionUser?.status ?? currentUser.data?.status ?? 'online';
+ const customStatus = sessionUser?.custom_status ?? '';
+ const emoji = sessionUser?.custom_status_emoji ?? '';
+ const seed = currentUser.data?.id ?? sessionUser?.id ?? username;
+ const avatarFileId = sessionUser?.avatar_file_id ?? currentUser.data?.avatar_file_id;
+
+ const signOut = useMutation({
+ mutationFn: () => logout(),
+ onSuccess: () => {
+ // Сессия в cookie уже погашена — сбрасываем локальное состояние.
+ disconnect();
+ useSessionStore.getState().reset();
+ queryClient.clear();
+ void navigate('/login', { replace: true });
+ },
+ });
+
+ const statusLabel = userStatuses.includes(status)
+ ? t(`user.status.${status}`)
+ : t('user.status.offline');
+
+ const onCopyId = useCallback(() => {
+ if (currentUser.data === undefined) {
+ return;
+ }
+ void navigator.clipboard?.writeText(currentUser.data.id);
+ }, [currentUser.data]);
+
+ return (
+
+ );
+}
diff --git a/web/src/pages/settings/AppearanceSettingsPage.tsx b/web/src/pages/settings/AppearanceSettingsPage.tsx
new file mode 100644
index 0000000..b1885b6
--- /dev/null
+++ b/web/src/pages/settings/AppearanceSettingsPage.tsx
@@ -0,0 +1,49 @@
+import { useTranslation } from 'react-i18next';
+
+import { SelectField } from '@/components/ui/Field';
+import { Card } from '@/components/ui/primitives';
+import { supportedLanguages, type SupportedLanguage } from '@/i18n';
+import { useUiStore, type Theme } from '@/stores/ui';
+
+/** Внешний вид: тема и язык. Хранятся локально, без запросов к серверу. */
+export default function AppearanceSettingsPage() {
+ const { t, i18n } = useTranslation();
+ const theme = useUiStore((state) => state.theme);
+ const setTheme = useUiStore((state) => state.setTheme);
+ const locale = i18n.resolvedLanguage === 'en' ? 'en' : 'ru';
+
+ const changeLanguage = (language: SupportedLanguage): void => {
+ window.localStorage.setItem('glchat.language', language);
+ void i18n.changeLanguage(language);
+ };
+
+ return (
+
+ {t('settings.appearance.title')}
+ {t('settings.appearance.description')}
+
+
+ setTheme(value === 'light' ? 'light' : ('dark' as Theme))}
+ options={[
+ { value: 'dark', label: t('theme.dark') },
+ { value: 'light', label: t('theme.light') },
+ ]}
+ />
+ changeLanguage(value === 'en' ? 'en' : 'ru')}
+ options={supportedLanguages.map((language) => ({
+ value: language,
+ label: t(`language.${language}`),
+ }))}
+ />
+
+
+ );
+}
diff --git a/web/src/pages/settings/InstanceSettingsPage.tsx b/web/src/pages/settings/InstanceSettingsPage.tsx
new file mode 100644
index 0000000..4ed1d48
--- /dev/null
+++ b/web/src/pages/settings/InstanceSettingsPage.tsx
@@ -0,0 +1,213 @@
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import {
+ fetchAudit,
+ fetchInstanceGuilds,
+ fetchInstanceSettings,
+ fetchInstanceUsers,
+ instanceAuditQueryKey,
+ instanceGuildsQueryKey,
+ instanceSettingsQueryKey,
+ instanceUsersQueryKey,
+} from '@/api/instance';
+import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
+import { Card } from '@/components/ui/primitives';
+import { formatDateTime } from '@/lib/identity';
+import { useCurrentUser } from '@/lib/hooks';
+
+const AUDIT_LIMIT = 50;
+
+/** Админский раздел инстанса: настройки, серверы, пользователи, аудит (read-only). */
+export default function InstanceSettingsPage() {
+ const { t, i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage ?? 'ru';
+ const currentUser = useCurrentUser();
+ const isAdmin = currentUser.data?.is_instance_admin === true;
+
+ const settings = useQuery({
+ queryKey: instanceSettingsQueryKey,
+ queryFn: ({ signal }) => fetchInstanceSettings(signal),
+ enabled: isAdmin,
+ retry: 0,
+ });
+ const guilds = useQuery({
+ queryKey: instanceGuildsQueryKey,
+ queryFn: ({ signal }) => fetchInstanceGuilds(signal),
+ enabled: isAdmin,
+ retry: 0,
+ });
+ const users = useQuery({
+ queryKey: instanceUsersQueryKey,
+ queryFn: ({ signal }) => fetchInstanceUsers(signal),
+ enabled: isAdmin,
+ retry: 0,
+ });
+ const audit = useQuery({
+ queryKey: instanceAuditQueryKey(AUDIT_LIMIT),
+ queryFn: ({ signal }) => fetchAudit(AUDIT_LIMIT, signal),
+ enabled: isAdmin,
+ retry: 0,
+ });
+
+ if (!isAdmin) {
+ return (
+
+ {t('settings.instance.title')}
+ {t('settings.instance.notAdmin')}
+
+ );
+ }
+
+ const settingsEntries =
+ settings.data === undefined ? [] : Object.entries(settings.data).slice(0, 30);
+
+ return (
+
+
+ {t('settings.instance.title')}
+ {t('settings.instance.description')}
+
+
+
+ {t('settings.instance.settings')}
+ {settings.isPending ? : null}
+ {settings.isError ? (
+ void settings.refetch()}
+ />
+ ) : null}
+ {!settings.isPending && !settings.isError && settingsEntries.length === 0 ? (
+ {t('settings.instance.settingsEmpty')}
+ ) : null}
+ {settingsEntries.length === 0 ? null : (
+
+ {settingsEntries.map(([key, value]) => (
+
+ - {key}
+ -
+ {format(value)}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {t('settings.instance.guilds', { count: guilds.data?.length ?? 0 })}
+
+ {guilds.isPending ? : null}
+ {guilds.isError ? (
+ void guilds.refetch()}
+ />
+ ) : null}
+ {guilds.data !== undefined && guilds.data.length === 0 ? (
+ {t('settings.instance.guildsEmpty')}
+ ) : null}
+ {guilds.data === undefined || guilds.data.length === 0 ? null : (
+
+ {guilds.data.map((guild) => (
+ -
+ {guild.name}
+
+ {guild.is_main ? `${t('settings.instance.mainBadge')} · ` : ''}
+ {t('settings.instance.memberCount', { count: guild.member_count })}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {t('settings.instance.users', { count: users.data?.length ?? 0 })}
+
+ {users.isPending ? : null}
+ {users.isError ? (
+ void users.refetch()} />
+ ) : null}
+ {users.data !== undefined && users.data.length === 0 ? (
+ {t('settings.instance.usersEmpty')}
+ ) : null}
+ {users.data === undefined || users.data.length === 0 ? null : (
+
+ {users.data.map((user) => (
+ -
+
+ {user.display_name} @{user.username}
+
+ {user.is_instance_admin ? (
+
+ {t('settings.instance.adminBadge')}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+
+
+ {t('settings.instance.audit', { count: audit.data?.length ?? 0 })}
+
+ {audit.isPending ? : null}
+ {audit.isError ? (
+ void audit.refetch()} />
+ ) : null}
+ {audit.data !== undefined && audit.data.length === 0 ? (
+ {t('settings.instance.auditEmpty')}
+ ) : null}
+ {audit.data === undefined || audit.data.length === 0 ? null : (
+
+ {audit.data.map((entry) => (
+ -
+
+ {entry.action}
+ {entry.target === undefined ? '' : ` → ${entry.target}`}
+
+
+ {formatDateTime(entry.created_at, locale)}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function format(value: unknown): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return String(value);
+ }
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return '—';
+ }
+}
diff --git a/web/src/pages/settings/PasswordSection.tsx b/web/src/pages/settings/PasswordSection.tsx
new file mode 100644
index 0000000..edcd8b5
--- /dev/null
+++ b/web/src/pages/settings/PasswordSection.tsx
@@ -0,0 +1,176 @@
+import { useCallback, useState, type FormEvent } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import { changePassword, stepUp } from '@/api/auth';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+import { errorCode } from '@/lib/format';
+
+/**
+ * Смена пароля. Если сервер отвечает `auth.step_up_required`, показываем
+ * форму подтверждения личности (пароль + код 2FA при необходимости) и после
+ * успешного step-up повторяем смену пароля один раз.
+ */
+export function PasswordSection({ twoFactorEnabled }: { twoFactorEnabled: boolean }) {
+ const { t } = useTranslation();
+ const [currentPassword, setCurrentPassword] = useState('');
+ const [newPassword, setNewPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+ const [formError, setFormError] = useState(null);
+ const [stepUpOpen, setStepUpOpen] = useState(false);
+ const [stepUpPassword, setStepUpPassword] = useState('');
+ const [stepUpCode, setStepUpCode] = useState('');
+ const [changed, setChanged] = useState(false);
+
+ const change = useMutation({
+ mutationFn: () => changePassword(currentPassword, newPassword),
+ onSuccess: () => {
+ setChanged(true);
+ setStepUpOpen(false);
+ setStepUpPassword('');
+ setStepUpCode('');
+ setCurrentPassword('');
+ setNewPassword('');
+ setConfirmPassword('');
+ },
+ onError: (error: unknown) => {
+ if (errorCode(error) === 'auth.step_up_required') {
+ setStepUpOpen(true);
+ }
+ },
+ });
+
+ const confirmIdentity = useMutation({
+ mutationFn: () => stepUp(stepUpPassword, twoFactorEnabled ? stepUpCode : undefined),
+ onSuccess: () => {
+ // Пароль подтверждён — повторяем исходное действие.
+ change.mutate();
+ },
+ });
+
+ const onSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ setChanged(false);
+ if (newPassword.length < 8) {
+ setFormError(t('auth.validation.passwordShort'));
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setFormError(t('auth.validation.passwordMismatch'));
+ return;
+ }
+ setFormError(null);
+ change.mutate();
+ };
+
+ const closeStepUp = useCallback(() => {
+ setStepUpOpen(false);
+ }, []);
+
+ const stepUpRequired = errorCode(change.error) === 'auth.step_up_required';
+
+ return (
+
+ {t('settings.security.passwordTitle')}
+
+
+ {stepUpOpen ? (
+
+ {t('settings.security.stepUpTitle')}
+
+ {t('settings.security.stepUpBody', {
+ totp: twoFactorEnabled ? t('settings.security.stepUpTotp') : '',
+ })}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/pages/settings/ProfileSettingsPage.tsx b/web/src/pages/settings/ProfileSettingsPage.tsx
new file mode 100644
index 0000000..04e9916
--- /dev/null
+++ b/web/src/pages/settings/ProfileSettingsPage.tsx
@@ -0,0 +1,175 @@
+import { useEffect, useState, type FormEvent } from 'react';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import { updateProfile, currentUserQueryKey, type UpdateProfileInput } from '@/api/users';
+import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
+import { Field, SelectField, TextAreaField } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+import { supportedLanguages, type SupportedLanguage } from '@/i18n';
+import { useCurrentUser } from '@/lib/hooks';
+import { useSessionStore } from '@/stores/session';
+import { userStatuses } from '@/api/types';
+
+/** Профиль: отображаемое имя, «о себе», своё состояние, статус и язык. */
+export default function ProfileSettingsPage() {
+ const { t, i18n } = useTranslation();
+ const queryClient = useQueryClient();
+ const currentUser = useCurrentUser();
+
+ const [displayName, setDisplayName] = useState('');
+ const [bio, setBio] = useState('');
+ const [customStatus, setCustomStatus] = useState('');
+ const [customEmoji, setCustomEmoji] = useState('');
+ const [status, setStatus] = useState('online');
+ const [locale, setLocale] = useState('ru');
+ const [saved, setSaved] = useState(false);
+
+ const user = currentUser.data;
+
+ useEffect(() => {
+ if (user === undefined) {
+ return;
+ }
+ setDisplayName(user.display_name);
+ setBio(user.bio);
+ setCustomStatus(user.custom_status);
+ setCustomEmoji(user.custom_status_emoji);
+ setStatus(user.status);
+ setLocale(user.locale === 'en' ? 'en' : 'ru');
+ }, [user]);
+
+ const save = useMutation({
+ mutationFn: (input: UpdateProfileInput) => updateProfile(input),
+ onSuccess: (updated) => {
+ queryClient.setQueryData(currentUserQueryKey, updated);
+ useSessionStore.getState().patchUser(updated);
+ setSaved(true);
+ if (updated.locale === 'en' || updated.locale === 'ru') {
+ window.localStorage.setItem('glchat.language', updated.locale);
+ void i18n.changeLanguage(updated.locale);
+ }
+ },
+ });
+
+ const onSubmit = (event: FormEvent): void => {
+ event.preventDefault();
+ setSaved(false);
+ save.mutate({
+ display_name: displayName.trim(),
+ bio: bio.trim(),
+ custom_status: customStatus.trim(),
+ custom_status_emoji: customEmoji.trim(),
+ status,
+ locale,
+ });
+ };
+
+ if (currentUser.isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (currentUser.isError || user === undefined) {
+ return (
+
+ void currentUser.refetch()} />
+
+ );
+ }
+
+ return (
+
+ {t('settings.profile.title')}
+ {t('settings.profile.description')}
+
+
+
+ );
+}
diff --git a/web/src/pages/settings/SecuritySettingsPage.tsx b/web/src/pages/settings/SecuritySettingsPage.tsx
new file mode 100644
index 0000000..37e76d5
--- /dev/null
+++ b/web/src/pages/settings/SecuritySettingsPage.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { PasswordSection } from '@/pages/settings/PasswordSection';
+import { SessionsSection } from '@/pages/settings/SessionsSection';
+import { TwoFactorSection } from '@/pages/settings/TwoFactorSection';
+
+/**
+ * Безопасность: смена пароля со step-up, список сессий с выходом на всех
+ * устройствах и включение 2FA с показом секрета и кодов восстановления.
+ *
+ * Контракт Фазы 1 не отдаёт флаг включённой 2FA в `GET /users/@me`, поэтому
+ * состояние «2FA включена» ведём локально: после успешного `2fa/enable`
+ * формы запрашивают код подтверждения.
+ */
+export default function SecuritySettingsPage() {
+ const { t } = useTranslation();
+ const [twoFactorEnabled, setTwoFactorEnabled] = useState(false);
+
+ return (
+
+
+ {t('settings.security.title')}
+ {t('settings.security.description')}
+
+
+
+ setTwoFactorEnabled(true)} />
+
+ );
+}
diff --git a/web/src/pages/settings/SessionsSection.tsx b/web/src/pages/settings/SessionsSection.tsx
new file mode 100644
index 0000000..a1c9af0
--- /dev/null
+++ b/web/src/pages/settings/SessionsSection.tsx
@@ -0,0 +1,109 @@
+import { useMutation, useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router';
+
+import { fetchSessions, logoutAll, sessionsQueryKey } from '@/api/auth';
+import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice';
+import { Button, Card } from '@/components/ui/primitives';
+import { formatDateTime } from '@/lib/identity';
+import { useGatewayStore } from '@/stores/gateway';
+import { useSessionStore } from '@/stores/session';
+import { getQueryClient } from '@/lib/queryClient';
+
+/** Список активных сессий и выход на всех устройствах. */
+export function SessionsSection() {
+ const { t, i18n } = useTranslation();
+ const navigate = useNavigate();
+ const locale = i18n.resolvedLanguage ?? 'ru';
+ const disconnect = useGatewayStore((state) => state.disconnect);
+
+ const sessions = useQuery({
+ queryKey: sessionsQueryKey,
+ queryFn: ({ signal }) => fetchSessions(signal),
+ });
+
+ const logoutAllMutation = useMutation({
+ mutationFn: () => logoutAll(),
+ onSuccess: () => {
+ // Все сессии погашены, включая текущую: чистим состояние и уходим на вход.
+ disconnect();
+ useSessionStore.getState().reset();
+ getQueryClient().clear();
+ void navigate('/login', { replace: true });
+ },
+ });
+
+ return (
+
+
+ {t('settings.security.sessionsTitle')}
+
+
+
+ {logoutAllMutation.isError ? (
+
+ ) : null}
+
+ {sessions.isPending ? : null}
+ {sessions.isError ? (
+ void sessions.refetch()}
+ />
+ ) : null}
+
+ {sessions.data !== undefined && sessions.data.length === 0 ? (
+ {t('settings.security.sessionsEmpty')}
+ ) : null}
+
+ {sessions.data !== undefined && sessions.data.length > 0 ? (
+
+ {sessions.data.map((session) => (
+ -
+
+
+ {session.user_agent === ''
+ ? t('settings.security.unknownAgent')
+ : session.user_agent}
+
+ {session.current ? (
+
+ {t('settings.security.sessionCurrent')}
+
+ ) : null}
+
+
+
+ - {t('settings.security.sessionIp')}:
+ - {session.ip}
+
+
+ - {t('settings.security.sessionCreated')}:
+ - {formatDateTime(session.created_at, locale)}
+
+
+ - {t('settings.security.sessionSeen')}:
+ - {formatDateTime(session.last_seen, locale)}
+
+
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/pages/settings/SettingsLayout.tsx b/web/src/pages/settings/SettingsLayout.tsx
new file mode 100644
index 0000000..4e819fb
--- /dev/null
+++ b/web/src/pages/settings/SettingsLayout.tsx
@@ -0,0 +1,54 @@
+import { Suspense } from 'react';
+import { useTranslation } from 'react-i18next';
+import { NavLink, Outlet } from 'react-router';
+
+import { RouteFallback } from '@/App';
+import { useCurrentUser } from '@/lib/hooks';
+
+/** Каркас настроек: вкладки профиля, безопасности, внешнего вида и инстанса. */
+export default function SettingsLayout() {
+ const { t } = useTranslation();
+ const currentUser = useCurrentUser();
+
+ const tabs = [
+ { to: 'profile', label: t('settings.tabs.profile') },
+ { to: 'security', label: t('settings.tabs.security') },
+ { to: 'appearance', label: t('settings.tabs.appearance') },
+ ...(currentUser.data?.is_instance_admin === true
+ ? [{ to: 'instance', label: t('settings.tabs.instance') }]
+ : []),
+ ];
+
+ return (
+
+
+ {t('settings.title')}
+
+ {t('settings.back')}
+
+
+
+
+
+ }>
+
+
+
+ );
+}
diff --git a/web/src/pages/settings/TwoFactorSection.tsx b/web/src/pages/settings/TwoFactorSection.tsx
new file mode 100644
index 0000000..6116515
--- /dev/null
+++ b/web/src/pages/settings/TwoFactorSection.tsx
@@ -0,0 +1,164 @@
+import { useState } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+
+import { enableTotp, setupTotp } from '@/api/auth';
+import { ErrorNotice } from '@/components/ui/ErrorNotice';
+import { Field } from '@/components/ui/Field';
+import { Button, Card } from '@/components/ui/primitives';
+
+/**
+ * Включение двухфакторной аутентификации: setup → секрет (и QR) → код →
+ * одноразовые коды восстановления, которые показываются ровно один раз.
+ */
+export function TwoFactorSection({
+ enabled,
+ onEnabled,
+}: {
+ enabled: boolean;
+ onEnabled: () => void;
+}) {
+ const { t } = useTranslation();
+ const [secret, setSecret] = useState(null);
+ const [otpauthUrl, setOtpauthUrl] = useState('');
+ const [code, setCode] = useState('');
+ const [recoveryCodes, setRecoveryCodes] = useState(null);
+ const [qrFailed, setQrFailed] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const setup = useMutation({
+ mutationFn: () => setupTotp(),
+ onSuccess: (payload) => {
+ setSecret(payload.secret);
+ setOtpauthUrl(payload.otpauth_url);
+ setRecoveryCodes(null);
+ setQrFailed(false);
+ },
+ });
+
+ const enable = useMutation({
+ mutationFn: () => enableTotp(code.trim()),
+ onSuccess: (codes) => {
+ setRecoveryCodes(codes);
+ setSecret(null);
+ setCode('');
+ onEnabled();
+ },
+ });
+
+ const copyRecovery = (): void => {
+ if (recoveryCodes === null) {
+ return;
+ }
+ void navigator.clipboard?.writeText(recoveryCodes.join('\n'));
+ setCopied(true);
+ };
+
+ return (
+
+
+
+ {t('settings.security.twoFactorTitle')}
+
+ {t(
+ enabled
+ ? 'settings.security.twoFactorEnabled'
+ : 'settings.security.twoFactorDisabled',
+ )}
+
+
+ {enabled || secret !== null ? null : (
+
+ )}
+
+
+ {setup.isError ? : null}
+
+ {secret !== null ? (
+
+ {t('settings.security.twoFactorSetupHint')}
+
+
+ {qrFailed || otpauthUrl === '' ? (
+
+ {t('settings.security.twoFactorQrUnavailable')}
+
+ ) : (
+
setQrFailed(true)}
+ />
+ )}
+
+ - {t('settings.security.twoFactorSecret')}
+ -
+ {secret}
+
+ - {t('settings.security.twoFactorUri')}
+ - {otpauthUrl}
+
+
+
+
+
+ ) : null}
+
+ {recoveryCodes !== null ? (
+
+
+ {t('settings.security.twoFactorEnabledDone')}
+
+ {t('settings.security.recoveryTitle')}
+ {t('settings.security.recoveryHint')}
+
+ {recoveryCodes.map((recoveryCode) => (
+ - {recoveryCode}
+ ))}
+
+
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/web/src/stores/gateway.ts b/web/src/stores/gateway.ts
new file mode 100644
index 0000000..102a129
--- /dev/null
+++ b/web/src/stores/gateway.ts
@@ -0,0 +1,147 @@
+import { create } from 'zustand';
+
+import {
+ connectGateway,
+ disconnectGateway,
+ parseGatewaySnapshot,
+ type GatewayDispatch,
+ type GatewayStatus,
+ type GatewayUser,
+} from '@/api/gateway';
+import { useSessionStore } from '@/stores/session';
+
+/**
+ * Состояние соединения со шлюзом. Данные приходят в session-store,
+ * здесь — только транспорт: статус, heartbeat, resume и переподключение.
+ */
+interface GatewayState {
+ status: GatewayStatus;
+ heartbeatIntervalMs: number | null;
+ sessionId: string | null;
+ attempts: number;
+ /** Признак того, что после INVALID_SESSION нужно уйти на /login. */
+ invalidated: boolean;
+ connect: (userId: string) => void;
+ disconnect: () => void;
+ reset: () => void;
+}
+
+const defaults = {
+ status: 'idle' as GatewayStatus,
+ heartbeatIntervalMs: null,
+ sessionId: null,
+ attempts: 0,
+ invalidated: false,
+};
+
+export const useGatewayStore = create((set, get) => ({
+ ...defaults,
+
+ connect: (userId) => {
+ if (get().status !== 'idle') {
+ return;
+ }
+ // userId пока не уходит на сервер (аутентификация — cookie на рукопожатии),
+ // но фиксирует, что снапшот принадлежит именно этому пользователю.
+ void userId;
+ connectGateway({
+ handlers: {
+ onStatus: (status) => {
+ set({
+ status,
+ attempts: status === 'reconnecting' ? get().attempts + 1 : get().attempts,
+ });
+ },
+ onInvalidSession: () => {
+ set({ invalidated: true, status: 'disconnected' });
+ useSessionStore.getState().reset();
+ void invalidateCurrentUser();
+ },
+ onResumeIncomplete: () => {
+ // Сервер не прислал снапшот — перечитываем профиль по REST.
+ void refetchCurrentUser();
+ },
+ onDispatch: (event) => {
+ dispatchGatewayEvent(event);
+ },
+ },
+ });
+ },
+
+ disconnect: () => {
+ disconnectGateway();
+ set({ ...defaults });
+ },
+
+ reset: () => {
+ disconnectGateway();
+ set({ ...defaults });
+ },
+}));
+
+/** Разбор события DISPATCH: в Фазе 1 обрабатываем READY/RESUMED, остальные — в диспетчер. */
+export function dispatchGatewayEvent(event: GatewayDispatch): void {
+ const session = useSessionStore.getState();
+ switch (event.t) {
+ case 'READY': {
+ const snapshot = parseGatewaySnapshot(event.d);
+ if (snapshot === null) {
+ return;
+ }
+ session.applyReady(snapshot);
+ useGatewayStore.setState({
+ status: 'connected',
+ heartbeatIntervalMs: snapshot.heartbeat_interval_ms,
+ sessionId: snapshot.session_id,
+ attempts: 0,
+ });
+ return;
+ }
+ case 'RESUMED': {
+ const user = parseResumedUser(event.d);
+ session.applyResumed(user);
+ useGatewayStore.setState({ status: 'connected', attempts: 0 });
+ return;
+ }
+ // Заготовка диспетчера Фазы 2: события приходят, но пока не меняют UI.
+ case 'GUILD_CREATE':
+ case 'GUILD_UPDATE':
+ case 'GUILD_DELETE':
+ case 'CHANNEL_CREATE':
+ case 'CHANNEL_UPDATE':
+ case 'CHANNEL_DELETE':
+ case 'MEMBER_UPDATE':
+ case 'PRESENCE_UPDATE':
+ return;
+ default:
+ // Неизвестные события игнорируем: контракт может расширяться.
+ return;
+ }
+}
+
+function parseResumedUser(payload: unknown): GatewayUser | null {
+ if (typeof payload !== 'object' || payload === null) {
+ return null;
+ }
+ const user = (payload as { user?: unknown }).user;
+ const snapshot = parseGatewaySnapshot({ user, guilds: [] });
+ return snapshot === null ? null : snapshot.user;
+}
+
+/** Ленивые импорты разрывают цикл «стор → query-client → стор». */
+async function invalidateCurrentUser(): Promise {
+ const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([
+ import('@/lib/queryClient'),
+ import('@/api/users'),
+ ]);
+ // removeQueries вместо invalidate: сессия мертва, повторный запрос не нужен.
+ getQueryClient().removeQueries({ queryKey: currentUserQueryKey });
+}
+
+async function refetchCurrentUser(): Promise {
+ const [{ getQueryClient }, { currentUserQueryKey }] = await Promise.all([
+ import('@/lib/queryClient'),
+ import('@/api/users'),
+ ]);
+ await getQueryClient().invalidateQueries({ queryKey: currentUserQueryKey });
+}
diff --git a/web/src/stores/session.ts b/web/src/stores/session.ts
new file mode 100644
index 0000000..343bc07
--- /dev/null
+++ b/web/src/stores/session.ts
@@ -0,0 +1,206 @@
+import { create } from 'zustand';
+
+import type { GatewayGuild, GatewaySnapshot, GatewayUser } from '@/api/gateway';
+import type { Channel, GuildSummary, User } from '@/api/types';
+
+/**
+ * Снапшот реального времени (READY/RESUMED) живёт здесь, а не в TanStack
+ * Query: Query отвечает за REST-данные (профиль, настройки, сессии), Zustand —
+ * за состояние, которое приходит событиями шлюза.
+ */
+
+/** Сервер снапшота: сводка для рейки + каналы/роли для сайдбара. */
+export interface SessionGuild extends GuildSummary {
+ channels: Channel[];
+ my_nickname?: string;
+}
+
+interface SessionState {
+ user: GatewayUser | null;
+ guilds: SessionGuild[];
+ dmChannels: Channel[];
+ sessionId: string | null;
+ selectedGuildId: string | null;
+ selectedChannelId: string | null;
+
+ applyReady: (snapshot: GatewaySnapshot) => void;
+ /** RESUMED приходит без снапшота — обновляем только то, что пришло. */
+ applyResumed: (user: GatewayUser | null) => void;
+ setUser: (user: GatewayUser | null) => void;
+ /** Синхронизация с REST-профилем (PATCH /users/@me возвращает полного user). */
+ patchUser: (user: User) => void;
+ upsertGuild: (guild: GuildSummary) => void;
+ removeGuild: (guildId: string) => void;
+ setGuildChannels: (guildId: string, channels: Channel[]) => void;
+ upsertChannel: (guildId: string, channel: Channel) => void;
+ selectGuild: (guildId: string | null) => void;
+ selectChannel: (guildId: string | null, channelId: string | null) => void;
+ reset: () => void;
+}
+
+interface SessionDefaults {
+ user: null;
+ guilds: SessionGuild[];
+ dmChannels: Channel[];
+ sessionId: null;
+ selectedGuildId: null;
+ selectedChannelId: null;
+}
+
+const defaults: SessionDefaults = {
+ user: null,
+ guilds: [],
+ dmChannels: [],
+ sessionId: null,
+ selectedGuildId: null,
+ selectedChannelId: null,
+};
+
+function toSessionGuild(guild: GatewayGuild): SessionGuild {
+ const summary: SessionGuild = {
+ id: guild.id,
+ name: guild.name,
+ owner_id: guild.owner_id,
+ is_main: guild.is_main,
+ member_count: guild.member_ids.length,
+ my_role_ids: guild.my_role_ids,
+ my_permissions: guild.my_permissions ?? [],
+ channels: guild.channels,
+ };
+ if (guild.icon_file_id !== undefined) {
+ summary.icon_file_id = guild.icon_file_id;
+ }
+ if (guild.my_nickname !== undefined) {
+ summary.my_nickname = guild.my_nickname;
+ }
+ return summary;
+}
+
+export const useSessionStore = create((set, get) => ({
+ ...defaults,
+
+ applyReady: (snapshot) => {
+ const guilds = snapshot.guilds.map(toSessionGuild);
+ const previous = get();
+ // Сохраняем выбор пользователя, если он ещё существует в снапшоте.
+ const selectedGuildId =
+ previous.selectedGuildId !== null && guilds.some((g) => g.id === previous.selectedGuildId)
+ ? previous.selectedGuildId
+ : null;
+ const channels =
+ selectedGuildId === null
+ ? []
+ : (guilds.find((g) => g.id === selectedGuildId)?.channels ?? []);
+ const selectedChannelId =
+ previous.selectedChannelId !== null &&
+ channels.some((c) => c.id === previous.selectedChannelId)
+ ? previous.selectedChannelId
+ : null;
+ set({
+ user: snapshot.user,
+ guilds,
+ dmChannels: snapshot.dm_channels,
+ sessionId: snapshot.session_id,
+ selectedGuildId,
+ selectedChannelId,
+ });
+ },
+
+ applyResumed: (user) => {
+ if (user !== null) {
+ set({ user });
+ }
+ },
+
+ setUser: (user) => {
+ set({ user });
+ },
+
+ patchUser: (user) => {
+ // PATCH /users/@me возвращает полный профиль — переносим его в снапшот,
+ // сохраняя открытые шлюзом поля присутствия, если сервер их не вернул.
+ const current = get().user;
+ const next: GatewayUser = {
+ id: user.id,
+ username: user.username,
+ display_name: user.display_name,
+ is_instance_admin: user.is_instance_admin,
+ badges: user.badges,
+ status: user.status,
+ custom_status: user.custom_status,
+ custom_status_emoji: user.custom_status_emoji,
+ };
+ const avatar = user.avatar_file_id ?? current?.avatar_file_id;
+ if (avatar !== undefined) {
+ next.avatar_file_id = avatar;
+ }
+ set({ user: next });
+ },
+
+ upsertGuild: (guild) => {
+ const guilds = get().guilds;
+ const index = guilds.findIndex((item) => item.id === guild.id);
+ if (index === -1) {
+ set({ guilds: [...guilds, { ...guild, channels: [] }] });
+ return;
+ }
+ const existing = guilds[index];
+ if (existing === undefined) {
+ return;
+ }
+ const merged: SessionGuild = { ...existing, ...guild, channels: existing.channels };
+ const next = [...guilds];
+ next[index] = merged;
+ set({ guilds: next });
+ },
+
+ removeGuild: (guildId) => {
+ const state = get();
+ set({
+ guilds: state.guilds.filter((guild) => guild.id !== guildId),
+ selectedGuildId: state.selectedGuildId === guildId ? null : state.selectedGuildId,
+ selectedChannelId: state.selectedGuildId === guildId ? null : state.selectedChannelId,
+ });
+ },
+
+ setGuildChannels: (guildId, channels) => {
+ set({
+ guilds: get().guilds.map((guild) => (guild.id === guildId ? { ...guild, channels } : guild)),
+ });
+ },
+
+ upsertChannel: (guildId, channel) => {
+ set({
+ guilds: get().guilds.map((guild) => {
+ if (guild.id !== guildId) {
+ return guild;
+ }
+ const index = guild.channels.findIndex((item) => item.id === channel.id);
+ if (index === -1) {
+ return { ...guild, channels: [...guild.channels, channel] };
+ }
+ const channels = [...guild.channels];
+ channels[index] = channel;
+ return { ...guild, channels };
+ }),
+ });
+ },
+
+ selectGuild: (guildId) => {
+ set({ selectedGuildId: guildId, selectedChannelId: null });
+ },
+
+ selectChannel: (guildId, channelId) => {
+ set({ selectedGuildId: guildId, selectedChannelId: channelId });
+ },
+
+ reset: () => {
+ set({ ...defaults });
+ },
+}));
+
+/** Селектор сервера по id (без подписки на весь список). */
+export function selectGuildById(guildId: string | null) {
+ return (state: SessionState): SessionGuild | null =>
+ guildId === null ? null : (state.guilds.find((guild) => guild.id === guildId) ?? null);
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 127f535..4120bc7 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -22,7 +22,10 @@ export default defineConfig({
'/api': 'http://127.0.0.1:8080',
'/healthz': 'http://127.0.0.1:8080',
'/readyz': 'http://127.0.0.1:8080',
- // Gateway (WS) is added in phase 1 together with the realtime server.
+ // Файлы аватаров/иконок отдаёт тот же инстанс.
+ '/files': 'http://127.0.0.1:8080',
+ // Шлюз Фазы 1: WebSocket на том же origin.
+ '/gateway': { target: 'ws://127.0.0.1:8080', ws: true },
},
},
test: {