feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса

REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
This commit is contained in:
2026-09-19 21:50:06 +03:00
parent 86dff94a02
commit 1b1a679827
63 changed files with 8477 additions and 187 deletions
+33 -13
View File
@@ -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
}
@@ -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;
+57 -1
View File
@@ -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")
+18 -2
View File
@@ -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 {
+33 -5
View File
@@ -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
}
+5
View File
@@ -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
+54
View File
@@ -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
+42
View File
@@ -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}
+158
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+483
View File
@@ -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
}
+631
View File
@@ -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
}
+339
View File
@@ -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),
})
}
+15 -19
View File
@@ -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"`
}
+9 -1
View File
@@ -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)
}
+24 -4
View File
@@ -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)
+7 -2
View File
@@ -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 доступен через
+11
View File
@@ -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 {
+100
View File
@@ -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
}
+20
View File
@@ -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)
}
+51 -1
View File
@@ -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
}