Files
glchat/cmd/glchat/main.go
T
grendervill 3dc200c196 feat(api): расширенная админ-панель инстанса (Фаза 7)
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (AGENT.md 3.2, 7.18):

- GET /instance/stats: рост пользователей и сообщений по дням, активность,
  размеры базы и файлов, топы серверов по участникам и сообщениям;
- GET /instance/users/{id}: профиль, активные устройства, серверы с ролями,
  события безопасности и аудит по пользователю;
- DELETE /instance/users/{id}/sessions/{sid} и POST .../reset-2fa: отзыв
  одного устройства и сброс второго фактора со step-up, аудитом и записью
  в события безопасности; чужой ключ администратора не сбрасывается;
- журнал инстанса: фильтры по действию, актору, цели, серверу и датам,
  пагинация с общим числом, список действий и выгрузка CSV (лимит 5/мин);
- список серверов: поиск по названию, владелец, главный сервер, пагинация;
- миграция 00025: нормализованное название сервера `name_lower` — SQLite
  lower() не знает кириллицу, поэтому регистр приводит приложение (как для
  текста сообщений), старые записи дополняются backfill'ом при старте.
2026-09-26 16:44:49 +03:00

227 lines
6.6 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"glchat/internal/auth"
"glchat/internal/config"
"glchat/internal/database"
"glchat/internal/gateway"
"glchat/internal/permissions"
"glchat/internal/retention"
"glchat/internal/server"
"glchat/internal/source"
"glchat/internal/store"
)
// Build metadata is injected with -ldflags "-X main.version=... -X main.commit=... -X main.buildDate=...".
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
// healthcheck используется HEALTHCHECK контейнера: собственный бинарник
// избавляет образ от curl и лишних пакетов.
if len(os.Args) > 1 && os.Args[1] == "healthcheck" {
os.Exit(healthcheck())
}
// Подкоманды обслуживания (AGENT.md 10.7): bootstrap-admin, reset-password,
// make-admin, remove-admin, totp-setup, totp-reset, cleanup, reindex.
// Остальные команды реализует shell-обёртка glchat.
if len(os.Args) > 1 {
switch os.Args[1] {
case "bootstrap-admin", "reset-password", "make-admin", "remove-admin", "totp-setup", "totp-reset",
"create-user", "cleanup", "reindex", "vapid-keys":
os.Exit(runCLI(os.Args[1:]))
}
}
if err := run(); err != nil {
slog.Error("glchat exited with error", slog.Any("error", err))
os.Exit(1)
}
}
func healthcheck() int {
addr := config.DefaultListenAddr
if cfg, err := config.Load(); err == nil {
addr = cfg.ListenAddr
}
host, port, err := net.SplitHostPort(addr)
if err != nil {
host, port = "127.0.0.1", "8080"
}
if host == "" || host == "0.0.0.0" || host == "::" {
host = "127.0.0.1"
}
url := "http://" + net.JoinHostPort(host, port) + "/healthz"
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 1
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return 1
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return 1
}
return 0
}
func run() error {
if v := os.Getenv("APP_VERSION"); v != "" {
version = v
}
if c := os.Getenv("APP_COMMIT"); c != "" {
commit = c
}
if b := os.Getenv("APP_BUILD_DATE"); b != "" {
buildDate = b
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("load configuration: %w", err)
}
cfg.Version = version
cfg.Commit = commit
cfg.BuildDate = buildDate
logger := newLogger(cfg)
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := database.Open(ctx, database.Options{
Path: cfg.DatabasePath,
ReadPool: cfg.ReadPoolSize,
CacheSize: cfg.SQLiteCache,
MmapSize: cfg.SQLiteMmap,
Migrate: true,
})
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer func() {
if err := db.Close(); err != nil {
logger.Error("close database", slog.Any("error", err))
}
}()
schemaVersion, err := db.SchemaVersion(ctx)
if err != nil {
return err
}
journalMode, err := db.JournalMode(ctx)
if err != nil {
return err
}
logger.Info("database ready",
slog.String("path", db.Path()),
slog.Int64("schema_version", schemaVersion),
slog.String("journal_mode", journalMode),
)
stopMaintenance := db.RunMaintenance(ctx, logger, cfg.Maintenance)
defer stopMaintenance()
st := store.New(db)
authService, err := auth.New(ctx, cfg, st, logger)
if err != nil {
return fmt.Errorf("initialize authentication: %w", err)
}
// Один калькулятор прав на процесс: HTTP-ручки и Gateway видят общий кэш.
calculator := permissions.NewCalculator(source.New(st))
gatewayService := gateway.New(st, authService, gateway.NewSnapshot(st, calculator), logger, cfg.AllowedOrigins())
// Один раз при старте дополняем индекс поиска у старых сообщений
// (кириллица в SQLite lower() не приводится, нормализуем в приложении).
if updated, err := st.BackfillSearchIndex(ctx, 500); err != nil {
logger.WarnContext(ctx, "backfill search index failed", slog.Any("error", err))
} else if updated > 0 {
logger.InfoContext(ctx, "search index backfilled", slog.Int("messages", updated))
}
// То же для названий серверов: поиск в админ-панели инстанса ищет по
// нормализованной копии (AGENT.md 7.18).
if updated, err := st.BackfillGuildNameLower(ctx, 500); err != nil {
logger.WarnContext(ctx, "backfill guild names failed", slog.Any("error", err))
} else if updated > 0 {
logger.InfoContext(ctx, "guild names backfilled", slog.Int("guilds", updated))
}
srv := server.New(cfg, db, logger, server.Deps{
Store: st, Auth: authService, Gateway: gatewayService, Permissions: calculator,
})
// Чистка данных инстанса: сессии, аудит и файлы без ссылок (AGENT.md 6.4).
cleaner := retention.New(st, retention.Options{
DataDir: cfg.DataDir,
AuditDays: cfg.RetainAuditDays,
OrphanHours: cfg.RetainOrphanHours,
Logger: logger,
})
stopRetention := cleaner.Run(ctx, cfg.RetainInterval)
defer stopRetention()
// Сторож голосовых состояний: убирает «призрачные» записи после сбоев.
srv.StartVoiceWatchdog(context.Background(), 0)
errCh := make(chan error, 1)
go func() {
logger.Info("http server listening",
slog.String("addr", cfg.ListenAddr),
slog.String("version", cfg.Version),
slog.String("domain", cfg.Domain),
)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Info("shutdown requested")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTTL)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("graceful shutdown: %w", err)
}
return nil
}
func newLogger(cfg config.Config) *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.Level(cfg.LogLevelNumeric())}
var handler slog.Handler
if cfg.LogFormat == "text" {
handler = slog.NewTextHandler(os.Stdout, opts)
} else {
handler = slog.NewJSONHandler(os.Stdout, opts)
}
return slog.New(handler).With(
slog.String("service", "glchat"),
slog.String("version", cfg.Version),
)
}