c0537d3c17
- internal/bootstrap: создание инстанс-админа, главного сервера, ролей «Администратор» и «Пользователь» (all permissions / дефолтный набор §6.3), общей текстовой комнаты; повторный запуск идемпотентен - админ получает флаг is_instance_admin и системный бейдж instance_admin - CLI: glchat bootstrap-admin, reset-password, make-admin, remove-admin (работают внутри контейнера через DATA_DIR) - auth: UserByEmail, SetPassword (отзывает сессии), RegisterAdmin (в обход флага регистрации — bootstrap выполняет установщик) - тесты: создание админа и главного сервера, идемпотентность, автовступление нового пользователя с ролью @user
240 lines
7.8 KiB
Go
240 lines
7.8 KiB
Go
// Package bootstrap выполняет первичную настройку инстанса (AGENT.md 10.3 шаг 10,
|
|
// 13 Фаза 1): инстанс-администратор, главный сервер, роли по умолчанию.
|
|
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"glchat/internal/auth"
|
|
"glchat/internal/crypto"
|
|
"glchat/internal/permissions"
|
|
"glchat/internal/store"
|
|
)
|
|
|
|
// DefaultMainGuildName — название главного сервера по умолчанию.
|
|
const DefaultMainGuildName = "Главный сервер"
|
|
|
|
// Options — параметры первичной настройки.
|
|
type Options struct {
|
|
Email string
|
|
Password string
|
|
Username string
|
|
InstanceName string
|
|
MainGuild string
|
|
Force bool
|
|
}
|
|
|
|
// Result — что было создано (используется CLI и smoke-тестом).
|
|
type Result struct {
|
|
UserID uint64
|
|
GuildID uint64
|
|
AdminRoleID uint64
|
|
MemberRoleID uint64
|
|
CreatedUser bool
|
|
CreatedGuild bool
|
|
AlreadyDone bool
|
|
}
|
|
|
|
// Runner выполняет первичную настройку поверх хранилища и сервиса аутентификации.
|
|
type Runner struct {
|
|
store *store.Store
|
|
auth *auth.Service
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func New(st *store.Store, authService *auth.Service, logger *slog.Logger) *Runner {
|
|
return &Runner{store: st, auth: authService, logger: logger}
|
|
}
|
|
|
|
// Run создаёт администратора инстанса и главный сервер; повторный запуск
|
|
// идемпотентен (AGENT.md 10.3: установщик можно перезапускать).
|
|
func (r *Runner) Run(ctx context.Context, opts Options) (*Result, error) {
|
|
email := crypto.NormalizeEmail(opts.Email)
|
|
if email == "" {
|
|
return nil, errors.New("email is required")
|
|
}
|
|
if opts.Username == "" {
|
|
// Логин по умолчанию — часть адреса до @, приведённая к допустимым символам.
|
|
opts.Username = sanitizeUsername(strings.SplitN(email, "@", 2)[0])
|
|
}
|
|
if err := crypto.ValidateUsername(opts.Username); err != nil {
|
|
return nil, fmt.Errorf("invalid admin username %q: %w", opts.Username, err)
|
|
}
|
|
|
|
result := &Result{}
|
|
admin, err := r.findOrCreateAdmin(ctx, opts, result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
settings, err := r.store.InstanceSettings(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load instance settings: %w", err)
|
|
}
|
|
|
|
if settings.MainGuildID != 0 {
|
|
guild, err := r.store.GetGuild(ctx, settings.MainGuildID)
|
|
if err == nil {
|
|
result.GuildID = guild.ID
|
|
result.AlreadyDone = !result.CreatedUser
|
|
if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
if !errors.Is(err, store.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
guildName := opts.MainGuild
|
|
if guildName == "" {
|
|
guildName = DefaultMainGuildName
|
|
}
|
|
guild, err := r.store.CreateGuild(ctx, store.CreateGuildParams{
|
|
Name: guildName,
|
|
OwnerID: admin.ID,
|
|
IsMain: true,
|
|
IsDiscoverable: false,
|
|
Description: "",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create main guild: %w", err)
|
|
}
|
|
result.GuildID = guild.ID
|
|
result.CreatedGuild = true
|
|
|
|
if err := r.store.SetInstanceSetting(ctx, "main_guild_id", strconv.FormatUint(guild.ID, 10)); err != nil {
|
|
return nil, fmt.Errorf("set main guild: %w", err)
|
|
}
|
|
if err := r.ensureMainGuildSetup(ctx, guild, admin, result); err != nil {
|
|
return nil, err
|
|
}
|
|
// Главный сервер всегда содержит общую текстовую комнату.
|
|
if _, err := r.store.CreateChannel(ctx, store.CreateChannelParams{
|
|
GuildID: &guild.ID, Type: store.ChannelText, Name: "общий", Position: 0,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("create main channel: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// findOrCreateAdmin находит администратора по email или создаёт его.
|
|
func (r *Runner) findOrCreateAdmin(ctx context.Context, opts Options, result *Result) (*store.User, error) {
|
|
email := crypto.NormalizeEmail(opts.Email)
|
|
existing, err := r.auth.UserByEmail(ctx, email)
|
|
if err == nil && existing != nil {
|
|
result.UserID = existing.ID
|
|
if !existing.IsInstanceAdmin {
|
|
if err := r.store.SetInstanceAdmin(ctx, existing.ID, true); err != nil {
|
|
return nil, fmt.Errorf("grant instance admin: %w", err)
|
|
}
|
|
}
|
|
// Пароль администратора, заданный установщиком, обновляем осознанно:
|
|
// без этого повторный запуск с новым паролем не менял бы доступ.
|
|
if opts.Password != "" {
|
|
if err := r.auth.SetPassword(ctx, existing.ID, opts.Password); err != nil {
|
|
return nil, fmt.Errorf("update admin password: %w", err)
|
|
}
|
|
}
|
|
return r.store.GetUser(ctx, existing.ID)
|
|
}
|
|
if err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
return nil, err
|
|
}
|
|
if opts.Password == "" {
|
|
return nil, errors.New("password is required to create the instance administrator")
|
|
}
|
|
|
|
user, _, _, err := r.auth.RegisterAdmin(ctx, auth.RegisterInput{
|
|
Username: opts.Username,
|
|
DisplayName: opts.Username,
|
|
Email: email,
|
|
Password: opts.Password,
|
|
Locale: "ru",
|
|
UserAgent: "glchat-bootstrap",
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create instance administrator: %w", err)
|
|
}
|
|
result.UserID = user.ID
|
|
result.CreatedUser = true
|
|
// Бейдж «Администратор сервера» отображается рядом с ником (AGENT.md 7.19).
|
|
if err := r.store.SetUserBadges(ctx, user.ID, []string{"instance_admin"}); err != nil {
|
|
r.logger.WarnContext(ctx, "failed to set admin badge", slog.Any("error", err))
|
|
}
|
|
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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var adminRole, defaultRole *store.Role
|
|
for i := range roles {
|
|
switch {
|
|
case permissions.Permission(roles[i].Permissions).Has(permissions.Administrator):
|
|
adminRole = &roles[i]
|
|
case roles[i].IsDefault:
|
|
defaultRole = &roles[i]
|
|
}
|
|
}
|
|
if adminRole == nil {
|
|
created, err := r.store.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)
|
|
}
|
|
adminRole = created
|
|
}
|
|
result.AdminRoleID = adminRole.ID
|
|
if defaultRole == nil {
|
|
created, err := r.store.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)
|
|
}
|
|
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 := r.store.AssignRole(ctx, guild.ID, owner.ID, adminRole.ID); err != nil {
|
|
return fmt.Errorf("assign administrator role: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func sanitizeUsername(value string) string {
|
|
cleaned := strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.':
|
|
return r
|
|
default:
|
|
return '_'
|
|
}
|
|
}, value)
|
|
if len(cleaned) < 2 {
|
|
cleaned = "admin"
|
|
}
|
|
if len(cleaned) > 32 {
|
|
cleaned = cleaned[:32]
|
|
}
|
|
return cleaned
|
|
}
|