00d86bb70c
Владельцу нужны 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 с заголовком "Authorization: OAuth <токен>" (не Bearer). Общее: - в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name, раньше они терялись; - почта считается подтверждённой, если провайдер её отдал (отдельного флага нет, адрес приходит только по соответствующему праву) — D-083; - новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example; - тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
312 lines
11 KiB
Go
312 lines
11 KiB
Go
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
|
||
}
|