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 ( +
+ +
+ ); + } + return ; +} +/** Общая заглушка на время загрузки ленивых страниц. */ +export function RouteFallback() { return ( -
-
-
-

{t('app.name')}

-

{t('app.tagline')}

-
-
- - - - -
-
- - -

{t('status.title')}

-

{t('status.description')}

- - {meta.isPending ? ( -

- {t('status.connecting')} -

- ) : null} - - {meta.isError ? ( -
-

{errorMessage(meta.error)}

- -
- ) : null} - - {meta.data ? ( -
- - - - - } - /> -
- ) : null} -
- -
- - {t('app.name')} · {t('footer.license')} - - {meta.data?.commit ?? ''} -
+
+
); } + +export const routes = [ + { + path: '/', + children: [ + { index: true, element: }, + { path: 'login', element: }, + { path: 'register', element: }, + { path: 'status', element: }, + { + element: , + children: [{ path: 'onboarding', element: }], + }, + { + element: , + children: [ + { + path: 'app', + element: , + children: [ + { index: true, element: }, + { path: 'empty', element: }, + { path: ':guildId', element: }, + { path: ':guildId/:channelId', element: }, + ], + }, + ], + }, + { + element: , + children: [ + { + path: 'settings', + element: , + children: [ + { index: true, element: }, + { path: 'profile', element: }, + { path: 'security', element: }, + { path: 'appearance', element: }, + { path: 'instance', element: }, + ], + }, + ], + }, + { path: '*', element: }, + ], + }, +]; + +export const router = createBrowserRouter(routes); + +export function App() { + return ( + }> + + + ); +} diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts new file mode 100644 index 0000000..ef9192e --- /dev/null +++ b/web/src/api/auth.ts @@ -0,0 +1,78 @@ +import { request } from './client'; +import type { SessionInfo, User } from './types'; + +export interface RegisterInput { + username: string; + display_name: string; + email: string; + password: string; + locale: string; +} + +export interface LoginInput { + email: string; + password: string; + totp_code?: string; +} + +export interface LogoutAllResponse { + ok: true; +} + +export const sessionsQueryKey = ['auth', 'sessions'] as const; + +export function register(input: RegisterInput): Promise<{ user: User }> { + return request<{ user: User }>('/auth/register', { method: 'POST', body: input }); +} + +export function login(input: LoginInput): Promise<{ user: User }> { + return request<{ user: User }>('/auth/login', { method: 'POST', body: input }); +} + +export function logout(): Promise<{ ok: true }> { + return request<{ ok: true }>('/auth/logout', { method: 'POST' }); +} + +export function logoutAll(): Promise { + return request('/auth/logout-all', { method: 'POST' }); +} + +export async function fetchSessions(signal?: AbortSignal): Promise { + const payload = await request<{ sessions: SessionInfo[] }>( + '/auth/sessions', + signal === undefined ? {} : { signal }, + ); + return payload.sessions; +} + +/** Подтверждение личности перед сменой пароля и включением 2FA. */ +export function stepUp(password: string, totpCode?: string): Promise<{ ok: true }> { + const body: { password: string; totp_code?: string } = { password }; + if (totpCode !== undefined && totpCode !== '') { + body.totp_code = totpCode; + } + return request<{ ok: true }>('/auth/step-up', { method: 'POST', body }); +} + +export function setupTotp(): Promise<{ secret: string; otpauth_url: string }> { + return request<{ secret: string; otpauth_url: string }>('/auth/2fa/setup', { method: 'POST' }); +} + +export async function enableTotp(code: string): Promise { + const payload = await request<{ recovery_codes: string[] }>('/auth/2fa/enable', { + method: 'POST', + body: { code }, + }); + return payload.recovery_codes; +} + +/** Смена пароля: требует предварительного step-up. */ +export function changePassword( + currentPassword: string, + newPassword: string, +): Promise<{ ok: true }> { + return request<{ ok: true }>('/users/@me/password', { + method: 'POST', + body: { current_password: currentPassword, new_password: newPassword }, + }); +} diff --git a/web/src/api/gateway.ts b/web/src/api/gateway.ts new file mode 100644 index 0000000..d7290f8 --- /dev/null +++ b/web/src/api/gateway.ts @@ -0,0 +1,633 @@ +import type { Channel, Role, UserStatus } from './types'; + +/** + * Клиент шлюза (WebSocket) Фазы 1. + * + * Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент + * отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie + * на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится + * экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`. + * + * Модуль намеренно не зависит от React и Zustand: он принимает колбэки, + * поэтому его можно проверить юнит-тестом с подставным WebSocket. + */ + +export const GatewayOp = { + DISPATCH: 0, + HEARTBEAT: 1, + IDENTIFY: 2, + RESUME: 3, + INVALID_SESSION: 4, + HELLO: 10, + HEARTBEAT_ACK: 11, + RECONNECT: 12, +} as const; + +export const GATEWAY_CLOSE_INVALID_SESSION = 4000; + +export interface GatewayHello { + heartbeat_interval_ms: number; + session_id: string; +} + +/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */ +export interface GatewayGuild { + id: string; + name: string; + owner_id: string; + is_main: boolean; + icon_file_id?: string; + channels: Channel[]; + roles: Role[]; + member_ids: string[]; + my_role_ids: string[]; + my_nickname?: string; + my_permissions?: string[]; +} + +export interface GatewayUser { + id: string; + username: string; + display_name: string; + avatar_file_id?: string; + is_instance_admin: boolean; + badges: string[]; + status?: UserStatus; + custom_status?: string; + custom_status_emoji?: string; +} + +export interface GatewaySnapshot { + user: GatewayUser; + guilds: GatewayGuild[]; + dm_channels: Channel[]; + read_states: unknown[]; + session_id: string; + heartbeat_interval_ms: number; +} + +export interface GatewayDispatch { + op: 0; + t: string; + s: number; + d: unknown; +} + +export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected'; + +export interface GatewayHandlers { + /** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */ + onDispatch?: (event: GatewayDispatch) => void; + onStatus?: (status: GatewayStatus) => void; + /** Сессия недействительна — нужно разлогинить пользователя. */ + onInvalidSession?: () => void; + /** RESUMED без снапшота: данные нужно перечитать по REST. */ + onResumeIncomplete?: () => void; +} + +export interface GatewayLogger { + warn: (message: string) => void; + error: (message: string) => void; +} + +export interface GatewayConnectionOptions { + url: string; + handlers?: GatewayHandlers; + /** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */ + socketFactory?: (url: string) => WebSocket; + logger?: GatewayLogger; +} + +const DEFAULT_HEARTBEAT_MS = 45_000; +const MAX_BACKOFF_MS = 30_000; +const BASE_BACKOFF_MS = 1_000; + +interface GatewayPacket { + op: number; + t?: string; + s?: number; + d?: unknown; +} + +function isGatewayPacket(value: unknown): value is GatewayPacket { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { op?: unknown }).op === 'number' + ); +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null ? (value as Record) : null; +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function parseChannel(value: unknown): Channel | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const name = asString(record['name']); + const type = asString(record['type']); + if (id === undefined || name === undefined) { + return null; + } + if (type !== 'text' && type !== 'voice' && type !== 'category') { + return null; + } + const channel: Channel = { + id, + name, + type, + position: asNumber(record['position']) ?? 0, + }; + const guildId = asString(record['guild_id']); + const parentId = asString(record['parent_id']); + const userLimit = asNumber(record['user_limit']); + const slowmode = asNumber(record['slowmode_seconds']); + if (guildId !== undefined) { + channel.guild_id = guildId; + } + if (parentId !== undefined) { + channel.parent_id = parentId; + } + if (userLimit !== undefined) { + channel.user_limit = userLimit; + } + if (slowmode !== undefined) { + channel.slowmode_seconds = slowmode; + } + if (typeof record['can_send'] === 'boolean') { + channel.can_send = record['can_send']; + } + if (typeof record['can_connect'] === 'boolean') { + channel.can_connect = record['can_connect']; + } + if (typeof record['can_view'] === 'boolean') { + channel.can_view = record['can_view']; + } + return channel; +} + +function parseRole(value: unknown): Role | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const name = asString(record['name']); + if (id === undefined || name === undefined) { + return null; + } + const permissions = record['permissions']; + return { + id, + name, + color: asNumber(record['color']) ?? 0, + position: asNumber(record['position']) ?? 0, + permissions: + typeof permissions === 'string' + ? permissions + : typeof permissions === 'number' + ? String(permissions) + : '0', + is_default: record['is_default'] === true, + hoist: record['hoist'] === true, + mentionable: record['mentionable'] === true, + }; +} + +function parseGatewayUser(value: unknown): GatewayUser | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const username = asString(record['username']); + if (id === undefined || username === undefined) { + return null; + } + const user: GatewayUser = { + id, + username, + display_name: asString(record['display_name']) ?? username, + is_instance_admin: record['is_instance_admin'] === true, + badges: asStringArray(record['badges']), + }; + const avatar = asString(record['avatar_file_id']); + const status = asString(record['status']); + const customStatus = asString(record['custom_status']); + const customEmoji = asString(record['custom_status_emoji']); + if (avatar !== undefined) { + user.avatar_file_id = avatar; + } + if (status !== undefined) { + user.status = status as UserStatus; + } + if (customStatus !== undefined) { + user.custom_status = customStatus; + } + if (customEmoji !== undefined) { + user.custom_status_emoji = customEmoji; + } + return user; +} + +function parseGuild(value: unknown): GatewayGuild | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const id = asString(record['id']); + const name = asString(record['name']); + if (id === undefined || name === undefined) { + return null; + } + const channels = Array.isArray(record['channels']) + ? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null) + : []; + const roles = Array.isArray(record['roles']) + ? record['roles'].map(parseRole).filter((item): item is Role => item !== null) + : []; + const guild: GatewayGuild = { + id, + name, + owner_id: asString(record['owner_id']) ?? '', + is_main: record['is_main'] === true, + channels, + roles, + member_ids: asStringArray(record['member_ids']), + my_role_ids: asStringArray(record['my_role_ids']), + my_permissions: asStringArray(record['my_permissions']), + }; + const icon = asString(record['icon_file_id']); + const nickname = asString(record['my_nickname']); + if (icon !== undefined) { + guild.icon_file_id = icon; + } + if (nickname !== undefined) { + guild.my_nickname = nickname; + } + return guild; +} + +/** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */ +export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null { + const record = asRecord(value); + if (record === null) { + return null; + } + const user = parseGatewayUser(record['user']); + if (user === null) { + return null; + } + const guilds = Array.isArray(record['guilds']) + ? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null) + : []; + const dmChannels = Array.isArray(record['dm_channels']) + ? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null) + : []; + return { + user, + guilds, + dm_channels: dmChannels, + read_states: Array.isArray(record['read_states']) ? record['read_states'] : [], + session_id: asString(record['session_id']) ?? '', + heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS, + }; +} + +export function gatewayUrl(): string { + if (typeof window === 'undefined') { + return '/gateway'; + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${window.location.host}/gateway`; +} + +export type GatewaySend = (packet: { op: number; d: unknown }) => void; + +export class GatewayConnection { + private readonly url: string; + private readonly handlers: GatewayHandlers; + private readonly socketFactory: (url: string) => WebSocket; + private readonly logger: GatewayLogger; + + private socket: WebSocket | null = null; + private status: GatewayStatus = 'idle'; + private heartbeatTimer: ReturnType | null = null; + private helloTimeout: ReturnType | null = null; + private reconnectTimer: ReturnType | null = null; + private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS; + private awaitingAck = false; + private attempt = 0; + private sessionId: string | null = null; + private lastSeq = 0; + private closedByUser = false; + + constructor(options: GatewayConnectionOptions) { + this.url = options.url; + this.handlers = options.handlers ?? {}; + this.logger = options.logger ?? { warn: () => undefined, error: () => undefined }; + this.socketFactory = + options.socketFactory ?? + ((url) => { + if (typeof WebSocket === 'undefined') { + throw new Error('WebSocket is not available in this environment'); + } + return new WebSocket(url); + }); + } + + getStatus(): GatewayStatus { + return this.status; + } + + getSessionId(): string | null { + return this.sessionId; + } + + getLastSeq(): number { + return this.lastSeq; + } + + connect(): void { + this.closedByUser = false; + if (this.socket !== null) { + return; + } + this.open(undefined); + } + + /** Плановое закрытие: без переподключения. */ + close(): void { + this.closedByUser = true; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.stopHeartbeat(); + const socket = this.socket; + this.socket = null; + if (socket !== null) { + socket.onopen = null; + socket.onmessage = null; + socket.onclose = null; + socket.onerror = null; + try { + socket.close(); + } catch { + // Сокет мог уже закрыться — это не ошибка. + } + } + this.setStatus('disconnected'); + } + + private open(reason: unknown): void { + this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting'); + let socket: WebSocket; + try { + socket = this.socketFactory(this.url); + } catch (error) { + this.logger.warn(`gateway: cannot open socket (${String(error)})`); + this.scheduleReconnect(); + return; + } + this.socket = socket; + if (reason !== undefined) { + this.logger.warn(`gateway: reconnecting (${String(reason)})`); + } + + socket.onopen = () => { + // Ждём HELLO; если его нет — соединение считаем нерабочим. + this.helloTimeout = setTimeout(() => { + this.logger.warn('gateway: HELLO timeout'); + this.dropSocket(); + }, 15_000); + }; + + socket.onmessage = (event: MessageEvent) => { + this.handleMessage(event.data); + }; + + socket.onerror = () => { + this.logger.warn('gateway: socket error'); + }; + + socket.onclose = (event: CloseEvent) => { + const wasCurrent = this.socket === socket; + this.socket = null; + this.stopHeartbeat(); + if (!wasCurrent || this.closedByUser) { + return; + } + if (event.code === GATEWAY_CLOSE_INVALID_SESSION) { + this.setStatus('disconnected'); + this.handlers.onInvalidSession?.(); + return; + } + this.scheduleReconnect(); + }; + } + + private dropSocket(): void { + const socket = this.socket; + this.socket = null; + this.stopHeartbeat(); + if (socket !== null) { + socket.onclose = null; + try { + socket.close(); + } catch { + // Игнорируем: сокет уже не нужен. + } + } + this.scheduleReconnect(); + } + + private scheduleReconnect(): void { + if (this.closedByUser || this.reconnectTimer !== null) { + return; + } + this.attempt += 1; + const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS); + const jitter = Math.floor(Math.random() * 250); + this.setStatus('reconnecting'); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.open('backoff'); + }, backoff + jitter); + } + + private setStatus(status: GatewayStatus): void { + if (this.status === status) { + return; + } + this.status = status; + this.handlers.onStatus?.(status); + } + + private send(op: number, d: unknown): void { + const socket = this.socket; + if (socket === null || socket.readyState !== 1) { + return; + } + socket.send(JSON.stringify({ op, d })); + } + + private handleMessage(raw: unknown): void { + if (typeof raw !== 'string') { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + this.logger.warn('gateway: cannot parse frame'); + return; + } + if (!isGatewayPacket(parsed)) { + return; + } + switch (parsed.op) { + case GatewayOp.HELLO: { + const data = asRecord(parsed.d); + this.heartbeatIntervalMs = + asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS; + this.sessionId = asString(data?.['session_id']) ?? this.sessionId; + if (this.helloTimeout !== null) { + clearTimeout(this.helloTimeout); + this.helloTimeout = null; + } + this.identify(); + this.startHeartbeat(); + return; + } + case GatewayOp.HEARTBEAT_ACK: { + this.awaitingAck = false; + return; + } + case GatewayOp.DISPATCH: { + const seq = asNumber(parsed.s) ?? 0; + if (seq > this.lastSeq) { + this.lastSeq = seq; + } + const type = asString(parsed.t) ?? ''; + if (type === 'READY') { + this.attempt = 0; + } + if (type === 'RESUMED' && parsed.d === null) { + this.handlers.onResumeIncomplete?.(); + } + this.setStatus('connected'); + this.handlers.onDispatch?.({ + op: 0, + t: type, + s: seq, + d: parsed.d, + }); + return; + } + case GatewayOp.INVALID_SESSION: { + const resumable = parsed.d === true; + if (resumable) { + this.logger.warn('gateway: invalid session, retrying resume'); + this.dropSocket(); + return; + } + this.sessionId = null; + this.lastSeq = 0; + this.closedByUser = true; + this.setStatus('disconnected'); + this.handlers.onInvalidSession?.(); + return; + } + case GatewayOp.RECONNECT: { + this.dropSocket(); + return; + } + default: + // Неизвестные opcode игнорируем: контракт может расширяться. + return; + } + } + + private identify(): void { + // Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии. + const token = ''; + if (this.sessionId !== null && this.lastSeq > 0) { + this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq }); + return; + } + this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 }); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.awaitingAck = false; + this.heartbeatTimer = setInterval(() => { + if (this.awaitingAck) { + this.logger.warn('gateway: heartbeat was not acknowledged'); + this.dropSocket(); + return; + } + this.awaitingAck = true; + this.send(GatewayOp.HEARTBEAT, null); + }, this.heartbeatIntervalMs); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer !== null) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + if (this.helloTimeout !== null) { + clearTimeout(this.helloTimeout); + this.helloTimeout = null; + } + } +} + +let sharedConnection: GatewayConnection | null = null; + +export function getGatewayConnection(): GatewayConnection | null { + return sharedConnection; +} + +/** Создаёт (или переиспользует) единственное соединение с шлюзом. */ +export function connectGateway(options: { + url?: string; + handlers: GatewayHandlers; + socketFactory?: (url: string) => WebSocket; +}): GatewayConnection { + if (sharedConnection !== null) { + sharedConnection.close(); + } + const socketFactory = options.socketFactory; + sharedConnection = new GatewayConnection({ + url: options.url ?? gatewayUrl(), + handlers: options.handlers, + ...(socketFactory === undefined ? {} : { socketFactory }), + }); + sharedConnection.connect(); + return sharedConnection; +} + +export function disconnectGateway(): void { + sharedConnection?.close(); + sharedConnection = null; +} diff --git a/web/src/api/guilds.ts b/web/src/api/guilds.ts new file mode 100644 index 0000000..0318fdf --- /dev/null +++ b/web/src/api/guilds.ts @@ -0,0 +1,82 @@ +import { request } from './client'; +import type { Channel, ChannelType, Guild, GuildMember, GuildSummary, Role } from './types'; + +export interface CreateChannelInput { + name: string; + type: ChannelType; + parent_id?: string; +} + +export interface UpdateGuildInput { + name?: string; + description?: string; +} + +export const guildQueryKey = (guildId: string) => ['guilds', guildId] as const; +export const guildChannelsQueryKey = (guildId: string) => ['guilds', guildId, 'channels'] as const; +export const guildMembersQueryKey = (guildId: string) => ['guilds', guildId, 'members'] as const; +export const guildRolesQueryKey = (guildId: string) => ['guilds', guildId, 'roles'] as const; + +export async function createGuild(name: string): Promise { + const payload = await request<{ guild: GuildSummary }>('/guilds', { + method: 'POST', + body: { name }, + }); + return payload.guild; +} + +export async function fetchGuild(guildId: string, signal?: AbortSignal): Promise { + const payload = await request<{ guild: Guild }>( + `/guilds/${encodeURIComponent(guildId)}`, + signal === undefined ? {} : { signal }, + ); + return payload.guild; +} + +export async function updateGuild(guildId: string, input: UpdateGuildInput): Promise { + const payload = await request<{ guild: Guild }>(`/guilds/${encodeURIComponent(guildId)}`, { + method: 'PATCH', + body: input, + }); + return payload.guild; +} + +export async function fetchChannels(guildId: string, signal?: AbortSignal): Promise { + const payload = await request<{ channels: Channel[] }>( + `/guilds/${encodeURIComponent(guildId)}/channels`, + signal === undefined ? {} : { signal }, + ); + return payload.channels; +} + +export async function createChannel(guildId: string, input: CreateChannelInput): Promise { + const payload = await request<{ channel: Channel }>( + `/guilds/${encodeURIComponent(guildId)}/channels`, + { method: 'POST', body: input }, + ); + return payload.channel; +} + +export async function fetchMembers(guildId: string, signal?: AbortSignal): Promise { + const payload = await request<{ members: GuildMember[] }>( + `/guilds/${encodeURIComponent(guildId)}/members`, + signal === undefined ? {} : { signal }, + ); + return payload.members; +} + +export async function fetchRoles(guildId: string, signal?: AbortSignal): Promise { + const payload = await request<{ roles: Role[] }>( + `/guilds/${encodeURIComponent(guildId)}/roles`, + signal === undefined ? {} : { signal }, + ); + return payload.roles; +} + +export function joinGuild(guildId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/join`, { method: 'POST' }); +} + +export function leaveGuild(guildId: string): Promise<{ ok: true }> { + return request<{ ok: true }>(`/guilds/${encodeURIComponent(guildId)}/leave`, { method: 'POST' }); +} diff --git a/web/src/api/instance.ts b/web/src/api/instance.ts new file mode 100644 index 0000000..80c9268 --- /dev/null +++ b/web/src/api/instance.ts @@ -0,0 +1,63 @@ +import { request } from './client'; +import type { AuditEntry, InstanceGuild, InstanceInfo, InstanceUser } from './types'; + +export interface InstanceSettings { + [key: string]: unknown; +} + +export const instanceQueryKey = ['instance'] as const; +export const instanceSettingsQueryKey = ['instance', 'settings'] as const; +export const instanceGuildsQueryKey = ['instance', 'guilds'] as const; +export const instanceUsersQueryKey = ['instance', 'users'] as const; +export const instanceAuditQueryKey = (limit: number) => ['instance', 'audit', limit] as const; + +/** Публичная информация об инстансе: нужна на логине/регистрации и в /app. */ +export async function fetchInstance(signal?: AbortSignal): Promise { + const payload = await request<{ instance: InstanceInfo }>( + '/instance', + signal === undefined ? {} : { signal }, + ); + return payload.instance; +} + +export async function fetchInstanceSettings(signal?: AbortSignal): Promise { + const payload = await request<{ settings: InstanceSettings }>( + '/instance/settings', + signal === undefined ? {} : { signal }, + ); + return payload.settings; +} + +export async function updateInstanceSettings( + settings: Partial, +): Promise { + const payload = await request<{ settings: InstanceSettings }>('/instance/settings', { + method: 'PATCH', + body: settings, + }); + return payload.settings; +} + +export async function fetchInstanceGuilds(signal?: AbortSignal): Promise { + const payload = await request<{ guilds: InstanceGuild[] }>( + '/instance/guilds', + signal === undefined ? {} : { signal }, + ); + return payload.guilds; +} + +export async function fetchInstanceUsers(signal?: AbortSignal): Promise { + const payload = await request<{ users: InstanceUser[] }>( + '/instance/users', + signal === undefined ? {} : { signal }, + ); + return payload.users; +} + +export async function fetchAudit(limit = 50, signal?: AbortSignal): Promise { + const payload = await request<{ entries: AuditEntry[] }>( + `/instance/audit?limit=${String(limit)}`, + signal === undefined ? {} : { signal }, + ); + return payload.entries; +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..456abff --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,159 @@ +/** + * Общие типы API Фазы 1. Все структуры строгие: поля, которых нет в контракте, + * не выдумываем, а необязательные помечаем `?` (на клиенте их может не быть + * в зависимости от прав и состояния сервера). + */ + +/** Виды присутствия, которые сервер принимает в PATCH /users/@me. */ +export const userStatuses = ['online', 'idle', 'dnd', 'invisible', 'offline'] as const; +export type UserStatus = (typeof userStatuses)[number]; + +/** Тип комнаты: текст, голосовой канал или категория. */ +export type ChannelType = 'text' | 'voice' | 'category'; + +/** Пользователь в объёме, достаточном для интерфейса (GET /users/{id}). */ +export interface User { + id: string; + username: string; + display_name: string; + bio: string; + status: UserStatus; + custom_status: string; + custom_status_emoji: string; + avatar_file_id?: string; + banner_file_id?: string; + is_instance_admin: boolean; + badges: string[]; + locale: string; + onboarding_completed: boolean; +} + +/** Урезанное представление пользователя (участники сервера, READY). */ +export interface UserSummary { + id: string; + username: string; + display_name: string; + avatar_file_id?: string; + is_instance_admin: boolean; + badges: string[]; +} + +export interface SessionInfo { + id: string; + user_agent: string; + ip: string; + created_at: string; + last_seen: string; + current: boolean; +} + +export interface Role { + id: string; + name: string; + color: number; + position: number; + /** Битовая маска прав (строка — чтобы не терять старшие биты в JSON). */ + permissions: string; + is_default: boolean; + hoist: boolean; + mentionable: boolean; +} + +export interface Channel { + id: string; + guild_id?: string; + name: string; + type: ChannelType; + position: number; + parent_id?: string; + user_limit?: number; + slowmode_seconds?: number; + can_view?: boolean; + can_send?: boolean; + can_connect?: boolean; +} + +export interface Guild { + id: string; + name: string; + icon_file_id?: string; + owner_id: string; + is_main: boolean; + description: string; + member_count: number; + roles: Role[]; + my_role_ids: string[]; + my_permissions: string[]; +} + +/** Сервер в списке GET /users/@me/guilds. */ +export interface GuildSummary { + id: string; + name: string; + icon_file_id?: string; + owner_id: string; + is_main: boolean; + member_count: number; + my_role_ids: string[]; + my_permissions: string[]; +} + +export interface GuildMember { + user_id: string; + username: string; + display_name: string; + nickname?: string; + avatar_file_id?: string; + status: UserStatus; + is_instance_admin: boolean; + joined_at: string; + role_ids: string[]; +} + +export interface InstanceInfo { + name: string; + version: string; + registration_enabled: boolean; + allow_guild_creation: boolean; + max_guilds_per_user: number; + max_members_per_guild: number; + max_message_length: number; + main_guild_id?: string; + user_count: number; + guild_count: number; +} + +/** Сервер из админского списка GET /instance/guilds. */ +export interface InstanceGuild { + id: string; + name: string; + member_count: number; + owner_id: string; + is_main: boolean; +} + +/** Пользователь из админского списка GET /instance/users. */ +export interface InstanceUser { + id: string; + username: string; + display_name: string; + is_instance_admin: boolean; + created_at: string; +} + +export interface AuditEntry { + id: string; + actor_id: string; + action: string; + target?: string; + created_at: string; + metadata?: Record; +} + +export function parsePermission(value: string): bigint | null { + try { + return BigInt(value); + } catch { + return null; + } +} diff --git a/web/src/api/users.ts b/web/src/api/users.ts new file mode 100644 index 0000000..538fe2c --- /dev/null +++ b/web/src/api/users.ts @@ -0,0 +1,61 @@ +import { request } from './client'; +import type { GuildSummary, User, UserSummary } from './types'; + +export interface UpdateProfileInput { + display_name?: string; + bio?: string; + status?: string; + custom_status?: string; + custom_status_emoji?: string; + locale?: string; +} + +export interface OnboardingInput { + display_name?: string; + bio?: string; + locale?: string; +} + +export const currentUserQueryKey = ['users', '@me'] as const; +export const myGuildsQueryKey = ['users', '@me', 'guilds'] as const; + +export function userQueryKey(userId: string) { + return ['users', userId] as const; +} + +export async function fetchCurrentUser(signal?: AbortSignal): Promise { + const payload = await request<{ user: User }>( + '/users/@me', + signal === undefined ? {} : { signal }, + ); + return payload.user; +} + +export async function updateProfile(input: UpdateProfileInput): Promise { + const payload = await request<{ user: User }>('/users/@me', { method: 'PATCH', body: input }); + return payload.user; +} + +export async function completeOnboarding(input: OnboardingInput): Promise { + const payload = await request<{ user: User }>('/users/@me/onboarding/complete', { + method: 'POST', + body: input, + }); + return payload.user; +} + +export async function fetchMyGuilds(signal?: AbortSignal): Promise { + const payload = await request<{ guilds: GuildSummary[] }>( + '/users/@me/guilds', + signal === undefined ? {} : { signal }, + ); + return payload.guilds; +} + +export async function fetchUser(userId: string, signal?: AbortSignal): Promise { + const payload = await request<{ user: UserSummary }>( + `/users/${encodeURIComponent(userId)}`, + signal === undefined ? {} : { signal }, + ); + return payload.user; +} diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx new file mode 100644 index 0000000..5371005 --- /dev/null +++ b/web/src/components/AuthGuard.tsx @@ -0,0 +1,62 @@ +import { Navigate, Outlet, useLocation } from 'react-router'; + +import { ErrorNotice, LoadingNotice } from '@/components/ui/ErrorNotice'; +import { useCurrentUser } from '@/lib/hooks'; + +interface AuthGuardProps { + /** + * Если `true`, пользователей с незавершённым онбордингом пускаем внутрь + * (нужно для /onboarding и /settings). + */ + allowIncompleteOnboarding?: boolean; +} + +/** + * Защита маршрутов: проверяет GET /users/@me. + * Неавторизованных отправляет на /login, не прошедших онбординг — на /onboarding. + */ +export function AuthGuard({ allowIncompleteOnboarding = false }: AuthGuardProps) { + const location = useLocation(); + const currentUser = useCurrentUser(); + + if (currentUser.isPending) { + return ( +
+ +
+ ); + } + + if (currentUser.isError) { + if (isUnauthorized(currentUser.error)) { + return ; + } + return ( +
+ void currentUser.refetch()} /> +
+ ); + } + + const user = currentUser.data; + if (!allowIncompleteOnboarding && !user.onboarding_completed) { + return ; + } + + return ; +} + +/** Коды, которые означают именно отсутствие сессии. */ +const unauthorizedCodes = new Set(['auth.unauthorized', 'auth.session_invalid', 'auth.required']); + +/** 401/403 или один из «сессионных» кодов; остальное — сеть или ошибка сервера. */ +export function isUnauthorized(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const candidate = error as { status?: unknown; code?: unknown }; + if (candidate.status === 401) { + return true; + } + return typeof candidate.code === 'string' && unauthorizedCodes.has(candidate.code); +} diff --git a/web/src/components/GatewayBridge.tsx b/web/src/components/GatewayBridge.tsx new file mode 100644 index 0000000..6da7bd3 --- /dev/null +++ b/web/src/components/GatewayBridge.tsx @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +import { useGatewayStore } from '@/stores/gateway'; + +/** + * Поднимает соединение со шлюзом, когда известно, кто вошёл в систему. + * Живёт в корне приложения и переживает переходы между страницами: + * состояние подключения хранится в zustand-сторе, а не в компоненте. + */ +export function GatewayBridge({ userId }: { userId: string | null }) { + const status = useGatewayStore((state) => state.status); + const connect = useGatewayStore((state) => state.connect); + const disconnect = useGatewayStore((state) => state.disconnect); + + useEffect(() => { + if (userId === null) { + if (status === 'idle') { + return; + } + disconnect(); + return; + } + if (status !== 'idle') { + return; + } + connect(userId); + }, [userId, status, connect, disconnect]); + + return null; +} diff --git a/web/src/components/ui/Avatar.tsx b/web/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..e50a83d --- /dev/null +++ b/web/src/components/ui/Avatar.tsx @@ -0,0 +1,38 @@ +import { avatarUrl, hueFromId, initials } from '@/lib/identity'; + +interface AvatarProps { + name: string; + /** Стабильный id для оттенка заглушки. */ + seed: string; + fileId?: string | undefined; + size?: 'sm' | 'md' | 'lg'; + /** Квадратная заглушка для серверов, круглая — для пользователей. */ + shape?: 'circle' | 'square'; +} + +const sizes = { + sm: 'h-7 w-7 text-[11px]', + md: 'h-9 w-9 text-xs', + lg: 'h-14 w-14 text-lg', +} as const; + +/** Аватар: картинка файлового сервиса либо инициалы на цветной заглушке. */ +export function Avatar({ name, seed, fileId, size = 'md', shape = 'circle' }: AvatarProps) { + const src = avatarUrl(fileId); + const hue = hueFromId(seed); + const radius = shape === 'circle' ? 'rounded-full' : 'rounded-[var(--radius-md)]'; + + return ( + + ); +} diff --git a/web/src/components/ui/ErrorNotice.tsx b/web/src/components/ui/ErrorNotice.tsx new file mode 100644 index 0000000..0f33d7d --- /dev/null +++ b/web/src/components/ui/ErrorNotice.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/primitives'; +import { useApiErrorMessage } from '@/lib/useApiErrorMessage'; + +interface ErrorNoticeProps { + error: unknown; + onRetry?: () => void; + className?: string; +} + +/** Сообщение об ошибке API с необязательной кнопкой повтора. */ +export function ErrorNotice({ error, onRetry, className = '' }: ErrorNoticeProps) { + const { t } = useTranslation(); + const describe = useApiErrorMessage(); + + return ( +
+

{describe(error)}

+ {onRetry === undefined ? null : ( + + )} +
+ ); +} + +/** Компактная строка состояния загрузки. */ +export function LoadingNotice({ label }: { label?: string }) { + const { t } = useTranslation(); + return ( +

+ {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 ( +
+ + {children({ id, describedBy })} + {hint === undefined ? null : ( +

+ {hint} +

+ )} + {error === null || error === undefined ? null : ( + + )} +
+ ); +} + +const controlClass = + 'w-full rounded-[var(--radius-md)] border border-border/60 bg-surface-2 px-3 py-2 text-fg placeholder:text-fg-muted/70 disabled:opacity-60'; + +export type InputFieldProps = { + label: string; + hint?: string | undefined; + error?: string | null | undefined; +} & InputHTMLAttributes; + +export function Field({ label, hint, error, className = '', ...rest }: InputFieldProps) { + return ( + + {({ id, describedBy }) => ( + + )} + + ); +} + +export type TextAreaFieldProps = { + label: string; + hint?: string | undefined; + error?: string | null | undefined; +} & TextareaHTMLAttributes; + +export function TextAreaField({ label, hint, error, className = '', ...rest }: TextAreaFieldProps) { + return ( + + {({ id, describedBy }) => ( +