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:
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 доступен через
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user