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 // 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, 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 != "" } // 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 } 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 }