Files
glchat/internal/config/config.go
T
grendervill 2a5d409d19 feat(auth): вход через VK ID и Яндекс ID
Владельцу нужны VK и Яндекс (Google/Discord/GitHub остаются в каталоге и
включаются своими ключами).

VK ID (id.vk.ru, OAuth 2.1):
- обязательный PKCE: code_challenge в запросе авторизации, code_verifier при
  обмене; верификатор выводится из подписанного state (HMAC), хранить нечего;
- device_id из callback уходит в обмен кода;
- для конфиденциальных приложений секрет передаётся как service_token, а не
  client_secret (и обмен идёт параметрами в теле, не Basic);
- профиль: POST /oauth2/user_info с client_id и access_token; права email и
  vkid.personal_info.

Яндекс ID:
- права login:email и login:info; секрет — в теле запроса обмена;
- профиль: GET login.yandex.ru/info?format=json с заголовком
   (не Bearer).

Общее:
- в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name,
  раньше они терялись;
- почта считается подтверждённой, если провайдер её отдал (отдельного флага нет,
  адрес приходит только по соответствующему праву) — D-083;
- новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example;
- тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
2026-09-27 17:57:48 +03:00

312 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package config
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
)
const (
DefaultListenAddr = "127.0.0.1:8080"
DefaultDataDir = "/opt/glchat/data"
DefaultLogLevel = "info"
)
type Config struct {
Domain string
FilesDomain string
InstanceName string
ListenAddr string
DataDir string
WebRoot string
// UpdatesDir — каталог с манифестами автообновления desktop-обёртки и
// артефактами релиза (docs/client-tauri.md §4). Наполняется релизным
// скриптом (scripts/desktop-release.sh), приложение только отдаёт файлы.
UpdatesDir string
DatabasePath string
SQLiteCache string
SQLiteMmap string
LogLevel string
LogFormat string
ReadPoolSize int
MaxUploadSize int64
TLSEnabled bool
// Секреты инстанса (генерируются установщиком, AGENT.md §10.3).
SessionPepper string
MasterKey string
TOTPEncryptionKey string
SessionTTLHours int
Argon2MemoryKiB int
Argon2Iterations int
Argon2Parallelism int
// LiveKit: голос, видео и шаринг экрана (AGENT.md 7.14).
LiveKitAPIKey string
LiveKitAPISecret string
LiveKitURL string
// LiveKitAPIURL — внутренний HTTP-адрес SFU для RoomService (модерация):
// в compose это http://livekit:7880, снаружи не публикуется.
LiveKitAPIURL string
// LiveKitTokenTTL — время жизни токена доступа к комнате (AGENT.md 7.14:
// по умолчанию 10 минут, клиент переиздаёт токен до истечения).
LiveKitTokenTTL time.Duration
Version string
Commit string
BuildDate string
ShutdownTTL time.Duration
Maintenance time.Duration
// Retention (AGENT.md 6.4, Фаза 5): сколько хранить сессии, записи аудита и
// файлы без ссылок. Период проверки — RetainInterval.
RetainAuditDays int
RetainOrphanHours int
RetainInterval time.Duration
// OAuth-провайдеры (AGENT.md 7.1, Фаза 7): включаются парами
// client id/secret, пустые значения означают «провайдер выключен».
OAuthVKClientID string
OAuthVKClientSecret string
OAuthYandexClientID string
OAuthYandexClientSecret string
OAuthGitHubClientID string
OAuthGitHubClientSecret string
OAuthGoogleClientID string
OAuthGoogleClientSecret string
OAuthDiscordClientID string
OAuthDiscordClientSecret string
// OAuthRedirectBase — внешний адрес инстанса для redirect_uri (пусто —
// берётся BaseURL из домена). Нужен, если клиент ходит по другому адресу.
OAuthRedirectBase string
// Web Push (AGENT.md 7.16, Фаза 7): приватный VAPID-ключ (PKCS#8 DER в
// base64, генерирует установщик) и контакт администратора для push-сервисов
// (mailto:… или https://…). Пустой ключ — Web Push выключен.
VAPIDPrivateKey string
VAPIDSubject string
// Unfurl ссылок (AGENT.md Фаза 7): сервер сам загружает превью страниц по
// ссылкам из сообщений. Выключенный unfurl не ходит в интернет вовсе.
UnfurlEnabled bool
UnfurlTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Domain: env("DOMAIN", "localhost"),
FilesDomain: env("FILES_DOMAIN", ""),
InstanceName: env("INSTANCE_NAME", "glchat"),
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
DataDir: env("DATA_DIR", DefaultDataDir),
WebRoot: env("WEB_ROOT", "/app/web"),
UpdatesDir: env("UPDATES_DIR", "/opt/glchat/updates"),
DatabasePath: env("DATABASE_PATH", ""),
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
TLSEnabled: envBool("TLS_ENABLED", true),
SessionPepper: env("SESSION_PEPPER", ""),
MasterKey: env("MASTER_KEY", ""),
TOTPEncryptionKey: env("TOTP_ENCRYPTION_KEY", ""),
SessionTTLHours: envInt("SESSION_TTL_HOURS", 720),
Argon2MemoryKiB: envInt("ARGON2_MEMORY_KIB", 19456),
Argon2Iterations: envInt("ARGON2_ITERATIONS", 2),
Argon2Parallelism: envInt("ARGON2_PARALLELISM", 1),
LiveKitAPIKey: env("LIVEKIT_API_KEY", ""),
LiveKitAPISecret: env("LIVEKIT_API_SECRET", ""),
LiveKitURL: env("LIVEKIT_URL", ""),
LiveKitAPIURL: env("LIVEKIT_API_URL", "http://livekit:7880"),
Version: env("APP_VERSION", "dev"),
Commit: env("APP_COMMIT", "none"),
BuildDate: env("APP_BUILD_DATE", "unknown"),
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
RetainAuditDays: envInt("RETENTION_AUDIT_DAYS", 180),
RetainOrphanHours: envInt("RETENTION_ORPHAN_HOURS", 24),
RetainInterval: time.Duration(envInt("RETENTION_INTERVAL_SECONDS", 3600)) * time.Second,
OAuthVKClientID: env("OAUTH_VK_CLIENT_ID", ""),
OAuthVKClientSecret: env("OAUTH_VK_CLIENT_SECRET", ""),
OAuthYandexClientID: env("OAUTH_YANDEX_CLIENT_ID", ""),
OAuthYandexClientSecret: env("OAUTH_YANDEX_CLIENT_SECRET", ""),
OAuthGitHubClientID: env("OAUTH_GITHUB_CLIENT_ID", ""),
OAuthGitHubClientSecret: env("OAUTH_GITHUB_CLIENT_SECRET", ""),
OAuthGoogleClientID: env("OAUTH_GOOGLE_CLIENT_ID", ""),
OAuthGoogleClientSecret: env("OAUTH_GOOGLE_CLIENT_SECRET", ""),
OAuthDiscordClientID: env("OAUTH_DISCORD_CLIENT_ID", ""),
OAuthDiscordClientSecret: env("OAUTH_DISCORD_CLIENT_SECRET", ""),
OAuthRedirectBase: env("OAUTH_REDIRECT_BASE", ""),
VAPIDPrivateKey: env("VAPID_PRIVATE_KEY", ""),
VAPIDSubject: env("VAPID_SUBJECT", ""),
UnfurlEnabled: envBool("UNFURL_ENABLED", true),
UnfurlTimeout: time.Duration(envInt("UNFURL_TIMEOUT_SECONDS", 5)) * time.Second,
}
if cfg.FilesDomain == "" {
cfg.FilesDomain = "files." + cfg.Domain
}
if cfg.DatabasePath == "" {
cfg.DatabasePath = cfg.DataDir + "/db/glchat.db"
}
if cfg.ReadPoolSize < 1 {
return Config{}, fmt.Errorf("SQLITE_READ_POOL must be >= 1, got %d", cfg.ReadPoolSize)
}
if _, err := parseLogLevel(cfg.LogLevel); err != nil {
return Config{}, err
}
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
return Config{}, fmt.Errorf("LOG_FORMAT must be json or text, got %q", cfg.LogFormat)
}
return cfg, nil
}
// Scheme выбирается установщиком: при установке без TLS (--skip-tls,
// например стенд без домена) клиент должен получать http/ws, а не https/wss.
func (c Config) Scheme() string {
if c.TLSEnabled {
return "https"
}
return "http"
}
// AllowedOrigins возвращает домены, с которых разрешены WebSocket и
// state-changing запросы (AGENT.md 9.7: точный allowlist, никаких *).
func (c Config) AllowedOrigins() []string {
origins := []string{
"https://" + c.Domain,
"http://" + c.Domain,
}
if c.FilesDomain != "" && c.FilesDomain != c.Domain {
origins = append(origins, "https://"+c.FilesDomain, "http://"+c.FilesDomain)
}
return origins
}
// VoiceEnabled сообщает, настроен ли LiveKit: без ключей голос выключен.
func (c Config) VoiceEnabled() bool {
return c.LiveKitAPIKey != "" && c.LiveKitAPISecret != "" && c.LiveKitURL != ""
}
// OAuthEnabled сообщает, настроен ли хотя бы один OAuth-провайдер (AGENT.md 7.1).
func (c Config) OAuthEnabled() bool { return len(c.OAuthProviders()) > 0 }
// WebPushEnabled сообщает, настроен ли Web Push: без VAPID-ключа подписки
// браузеров принимать некуда (AGENT.md 7.16).
func (c Config) WebPushEnabled() bool { return strings.TrimSpace(c.VAPIDPrivateKey) != "" }
// WebAuthnEnabled сообщает, годится ли домен инстанса как RP ID для passkeys.
// WebAuthn не работает на IP-адресах (исключение — localhost), поэтому стенд
// без домена честно отдаёт `passkeys_enabled: false`, а не ломает вход.
func (c Config) WebAuthnEnabled() bool {
host := strings.TrimSpace(c.Domain)
if host == "" {
return false
}
if parsed, _, err := net.SplitHostPort(host); err == nil {
host = parsed
}
if strings.EqualFold(host, "localhost") {
return true
}
return net.ParseIP(host) == nil
}
// OAuthProviders перечисляет включённые провайдеры в стабильном порядке.
func (c Config) OAuthProviders() []string {
providers := make([]string, 0, 5)
for _, candidate := range []struct {
id string
client string
secret string
}{
{"vk", c.OAuthVKClientID, c.OAuthVKClientSecret},
{"yandex", c.OAuthYandexClientID, c.OAuthYandexClientSecret},
{"github", c.OAuthGitHubClientID, c.OAuthGitHubClientSecret},
{"google", c.OAuthGoogleClientID, c.OAuthGoogleClientSecret},
{"discord", c.OAuthDiscordClientID, c.OAuthDiscordClientSecret},
} {
if candidate.client != "" && candidate.secret != "" {
providers = append(providers, candidate.id)
}
}
return providers
}
func (c Config) BaseURL() string { return c.Scheme() + "://" + c.Domain }
func (c Config) FilesURL() string { return c.Scheme() + "://" + c.FilesDomain }
func (c Config) GatewayURL() string {
scheme := "ws"
if c.TLSEnabled {
scheme = "wss"
}
return scheme + "://" + c.Domain + "/gateway"
}
func (c Config) LogLevelNumeric() int {
level, err := parseLogLevel(c.LogLevel)
if err != nil {
return 0
}
return level
}
func parseLogLevel(level string) (int, error) {
switch level {
case "debug":
return -4, nil
case "info":
return 0, nil
case "warn", "warning":
return 4, nil
case "error":
return 8, nil
default:
return 0, fmt.Errorf("unknown LOG_LEVEL %q", level)
}
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func envInt(key string, fallback int) int {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
n, err := strconv.Atoi(v)
if err != nil {
return fallback
}
return n
}
func envBool(key string, fallback bool) bool {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
parsed, err := strconv.ParseBool(v)
if err != nil {
return fallback
}
return parsed
}
func envInt64(key string, fallback int64) int64 {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return fallback
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return fallback
}
return n
}