3dc200c196
Статистика, карточка пользователя и работа с журналом для администратора
инстанса (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'ом при старте.
227 lines
6.6 KiB
Go
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),
|
|
)
|
|
}
|