Files
dsfree2api/internal/config/config.go
T

532 lines
16 KiB
Go

package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/BurntSushi/toml"
)
const DefaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
// Turnstile token providers: api calls a hosted solver, browser drives a
// local Chrome/Edge over CDP, manual only ever uses imported cookies.
const (
ProviderAPI = "api"
ProviderBrowser = "browser"
ProviderManual = "manual"
)
type Turnstile struct {
Enabled bool `toml:"enabled" json:"enabled"`
Provider string `toml:"provider" json:"provider"`
APIURL string `toml:"api_url" json:"api_url"`
APIKey string `toml:"api_key" json:"api_key"`
SiteKey string `toml:"sitekey" json:"sitekey"`
Action string `toml:"action" json:"action"`
TimeoutSeconds int `toml:"timeout_seconds" json:"timeout_seconds"`
CookieTTLSeconds int `toml:"cookie_ttl_seconds" json:"cookie_ttl_seconds"`
Retries int `toml:"retries" json:"retries"`
RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"`
// provider = "browser"
BrowserPath string `toml:"browser_path" json:"browser_path"`
BrowserHeadless bool `toml:"browser_headless" json:"browser_headless"`
BrowserUserDataDir string `toml:"browser_user_data_dir" json:"browser_user_data_dir"`
BrowserTimezone string `toml:"browser_timezone" json:"browser_timezone"`
BrowserLocale string `toml:"browser_locale" json:"browser_locale"`
}
// ProviderValue normalizes the token provider ("api" when unset).
func (t Turnstile) ProviderValue() string {
p := strings.ToLower(strings.TrimSpace(t.Provider))
if p == "" {
return ProviderAPI
}
return p
}
type Site struct {
Code string `toml:"-"`
BaseURL string `toml:"base_url" json:"base_url"`
AJAXURL string `toml:"ajax_url" json:"ajax_url"`
SiteKey string `toml:"sitekey" json:"sitekey"`
VerifyAction string `toml:"verify_action" json:"verify_action"`
Language string `toml:"language" json:"language"`
Enabled bool `toml:"enabled" json:"enabled"`
}
type Model struct {
ID string `toml:"-"`
Site string `toml:"site" json:"site"`
UpstreamID string `toml:"upstream_id" json:"upstream_id"`
Label string `toml:"label" json:"label"`
PagePath string `toml:"page_path" json:"page_path"`
BotID int `toml:"bot_id" json:"bot_id"`
PostID int `toml:"post_id" json:"post_id"`
Enabled bool `toml:"enabled" json:"enabled"`
}
type Config struct {
Server struct {
Host string `toml:"host" json:"host"`
Port int `toml:"port" json:"port"`
LogLevel string `toml:"log_level" json:"log_level"`
} `toml:"server" json:"server"`
Security struct {
APIKeys []string `toml:"api_keys" json:"api_keys"`
} `toml:"security" json:"security"`
Admin struct {
Enabled bool `toml:"enabled" json:"enabled"`
Host string `toml:"host" json:"host"`
Port int `toml:"port" json:"port"`
Password string `toml:"password" json:"password"`
} `toml:"admin" json:"admin"`
Limits struct {
MaxConcurrentPerSite int `toml:"max_concurrent_per_site" json:"max_concurrent_per_site"`
RatePerMinute float64 `toml:"rate_per_minute" json:"rate_per_minute"`
} `toml:"limits" json:"limits"`
Proxy struct {
URL string `toml:"url" json:"url"`
FallbackURLs []string `toml:"fallback_urls" json:"fallback_urls"`
SlowStartSeconds float64 `toml:"slow_start_seconds" json:"slow_start_seconds"`
} `toml:"proxy" json:"proxy"`
Upstream struct {
Timeout float64 `toml:"timeout" json:"timeout"`
StreamTimeout float64 `toml:"stream_timeout" json:"stream_timeout"`
ConfigTTLSeconds int `toml:"config_ttl_seconds" json:"config_ttl_seconds"`
AutoRefresh bool `toml:"auto_refresh" json:"auto_refresh"`
RefreshRetries int `toml:"refresh_retries" json:"refresh_retries"`
RetryBackoffSeconds float64 `toml:"retry_backoff_seconds" json:"retry_backoff_seconds"`
UserAgent string `toml:"user_agent" json:"user_agent"`
CrossSiteFailover bool `toml:"cross_site_failover" json:"cross_site_failover"`
ContinueRounds int `toml:"continue_rounds" json:"continue_rounds"`
} `toml:"upstream" json:"upstream"`
Runtime struct {
DataDir string `toml:"data_dir" json:"data_dir"`
} `toml:"runtime" json:"runtime"`
Turnstile Turnstile `toml:"turnstile" json:"turnstile"`
Sites map[string]*Site `toml:"sites" json:"sites"`
Models map[string]*Model `toml:"models" json:"models"`
mu sync.RWMutex `toml:"-"`
path string `toml:"-"`
}
func DefaultTurnstile() Turnstile {
return Turnstile{
Enabled: false,
Provider: ProviderAPI,
APIURL: "",
APIKey: "",
SiteKey: "0x4AAAAAADlLZ3ljqZP6cQwq",
Action: "chat",
TimeoutSeconds: 90,
CookieTTLSeconds: 10800,
Retries: 5,
RetryBackoffSeconds: 1.5,
}
}
func DefaultSites(sitekey string) map[string]*Site {
return map[string]*Site{
"de": {
Code: "de", BaseURL: "https://deepseek.de", AJAXURL: "https://deepseek.de/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
"es": {
Code: "es", BaseURL: "https://deepseek.es", AJAXURL: "https://deepseek.es/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "es-ES,es;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
"fr": {
Code: "fr", BaseURL: "https://deepseek.fr", AJAXURL: "https://deepseek.fr/wp-admin/admin-ajax.php",
SiteKey: sitekey, VerifyAction: "deepseek_ts_verify",
Language: "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", Enabled: true,
},
}
}
func DefaultModels() map[string]*Model {
rows := [][7]any{
{"deepseek-v4-flash-de", "de", "deepseek-v4-flash", "DeepSeek V4-Flash DE", "/", 27487, 106},
{"deepseek-v4-pro-de", "de", "deepseek-v4-pro", "DeepSeek V4-Pro DE", "/pro/", 27533, 27177},
{"deepseek-v4-flash-es", "es", "deepseek-v4-flash", "DeepSeek V4-Flash ES", "/", 27623, 27568},
{"deepseek-v4-pro-es", "es", "deepseek-v4-pro", "DeepSeek V4-Pro ES", "/pro/", 27637, 27615},
{"deepseek-v4-flash-fr", "fr", "deepseek-v4-flash", "DeepSeek V4-Flash FR", "/", 27645, 27569},
{"deepseek-v4-pro-fr", "fr", "deepseek-v4-pro", "DeepSeek V4-Pro FR", "/pro/", 27647, 27616},
}
out := make(map[string]*Model, len(rows))
for _, r := range rows {
id := r[0].(string)
out[id] = &Model{
ID: id, Site: r[1].(string), UpstreamID: r[2].(string), Label: r[3].(string),
PagePath: r[4].(string), BotID: r[5].(int), PostID: r[6].(int), Enabled: true,
}
}
return out
}
func newConfig() *Config {
c := &Config{}
c.Server.Host = "0.0.0.0"
c.Server.Port = 8000
c.Server.LogLevel = "INFO"
c.Admin.Enabled = true
c.Admin.Host = "127.0.0.1"
c.Admin.Port = 8001
c.Limits.MaxConcurrentPerSite = 4
c.Limits.RatePerMinute = 0
c.Proxy.SlowStartSeconds = 8
c.Upstream.Timeout = 60
c.Upstream.StreamTimeout = 300
c.Upstream.ConfigTTLSeconds = 60
c.Upstream.AutoRefresh = true
c.Upstream.RefreshRetries = 4
c.Upstream.RetryBackoffSeconds = 1
c.Upstream.UserAgent = DefaultUserAgent
c.Upstream.CrossSiteFailover = true
c.Upstream.ContinueRounds = 20
c.Runtime.DataDir = "./data"
c.Turnstile = DefaultTurnstile()
c.Sites = DefaultSites(c.Turnstile.SiteKey)
c.Models = DefaultModels()
return c
}
// Load reads the TOML config (if present), applies defaults, then env overrides.
func Load(path string) (*Config, error) {
cfg := newConfig()
if path == "" {
path = "config.toml"
}
cfg.path = path
var md toml.MetaData
if raw, err := os.ReadFile(path); err == nil {
md, err = toml.Decode(string(raw), cfg)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
cfg.applyDefaults(&md)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read %s: %w", path, err)
} else {
cfg.applyDefaults(nil)
}
if err := cfg.applyEnv(); err != nil {
return nil, err
}
if cfg.Sites == nil {
cfg.Sites = DefaultSites(cfg.Turnstile.SiteKey)
}
if cfg.Models == nil {
cfg.Models = DefaultModels()
}
if err := cfg.Reindex(); err != nil {
return nil, err
}
return cfg, nil
}
// Reindex restores derived fields (site codes / model ids) and validates the
// site-model wiring. It is safe to call after mutating the config at runtime.
func (c *Config) Reindex() error {
if c.Sites == nil {
c.Sites = DefaultSites(c.Turnstile.SiteKey)
}
if c.Models == nil {
c.Models = DefaultModels()
}
for code, s := range c.Sites {
if s == nil {
return fmt.Errorf("site %q is null", code)
}
s.Code = code
if s.SiteKey == "" {
s.SiteKey = c.Turnstile.SiteKey
}
if s.VerifyAction == "" {
s.VerifyAction = "deepseek_ts_verify"
}
if s.AJAXURL == "" {
s.AJAXURL = strings.TrimRight(s.BaseURL, "/") + "/wp-admin/admin-ajax.php"
}
if s.BaseURL == "" {
return fmt.Errorf("site %q: base_url is required", code)
}
}
for id, m := range c.Models {
if m == nil {
return fmt.Errorf("model %q is null", id)
}
m.ID = id
if m.Site == "" {
return fmt.Errorf("model %q: site is required", id)
}
if _, ok := c.Sites[m.Site]; !ok {
return fmt.Errorf("model %q: unknown site %q", id, m.Site)
}
if m.PagePath == "" {
m.PagePath = "/"
}
}
return nil
}
// applyDefaults fills booleans that were absent from the file (TOML decodes
// missing bools as false, but the project defaults are true).
func (c *Config) applyDefaults(md *toml.MetaData) {
def := func(defined bool, dst *bool, want bool) {
if !defined {
*dst = want
}
}
if md == nil {
c.Turnstile.Enabled = true
c.Upstream.AutoRefresh = true
c.Upstream.ContinueRounds = 20
for _, s := range c.Sites {
s.Enabled = true
}
for _, m := range c.Models {
m.Enabled = true
}
return
}
def(md.IsDefined("turnstile", "enabled"), &c.Turnstile.Enabled, true)
def(md.IsDefined("upstream", "auto_refresh"), &c.Upstream.AutoRefresh, true)
for code, s := range c.Sites {
def(md.IsDefined("sites", code, "enabled"), &s.Enabled, true)
}
for id, m := range c.Models {
def(md.IsDefined("models", id, "enabled"), &m.Enabled, true)
}
}
func (c *Config) applyEnv() error {
str := func(key string) *string {
if v, ok := os.LookupEnv(key); ok {
return &v
}
return nil
}
if v := str("HOST"); v != nil {
c.Server.Host = *v
}
if v := str("LOG_LEVEL"); v != nil {
c.Server.LogLevel = *v
}
if v := str("PORT"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 || n > 65535 {
return fmt.Errorf("invalid PORT %q (expected 1-65535)", *v)
}
c.Server.Port = n
}
if v := str("API_KEYS"); v != nil {
c.Security.APIKeys = splitList(*v)
}
if v := str("PROXY_URL"); v != nil {
c.Proxy.URL = strings.TrimSpace(*v)
}
if v := str("PROXY_FALLBACK_URLS"); v != nil {
c.Proxy.FallbackURLs = splitList(*v)
}
if v := str("PROXY_SLOW_START_SECONDS"); v != nil {
f, err := strconv.ParseFloat(strings.TrimSpace(*v), 64)
if err != nil || f < 0 {
return fmt.Errorf("invalid PROXY_SLOW_START_SECONDS %q", *v)
}
c.Proxy.SlowStartSeconds = f
}
if v := str("TURNSTILE_API_KEY"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.APIKey = strings.TrimSpace(*v)
}
if v := str("TURNSTILE_PROVIDER"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.Provider = strings.ToLower(strings.TrimSpace(*v))
}
if v := str("TURNSTILE_BROWSER_PATH"); v != nil && strings.TrimSpace(*v) != "" {
c.Turnstile.BrowserPath = strings.TrimSpace(*v)
}
if v := str("TURNSTILE_ENABLED"); v != nil {
b, err := strconv.ParseBool(strings.TrimSpace(*v))
if err != nil {
return fmt.Errorf("invalid TURNSTILE_ENABLED %q", *v)
}
c.Turnstile.Enabled = b
}
if v := str("ADMIN_PASSWORD"); v != nil {
c.Admin.Password = *v
}
if v := str("ADMIN_HOST"); v != nil {
c.Admin.Host = *v
}
if v := str("ADMIN_PORT"); v != nil {
n, err := strconv.Atoi(strings.TrimSpace(*v))
if err != nil || n < 1 || n > 65535 {
return fmt.Errorf("invalid ADMIN_PORT %q", *v)
}
c.Admin.Port = n
}
if v := str("DATA_DIR"); v != nil && strings.TrimSpace(*v) != "" {
c.Runtime.DataDir = strings.TrimSpace(*v)
}
if v := str("ADMIN_ENABLED"); v != nil {
b, err := strconv.ParseBool(strings.TrimSpace(*v))
if err != nil {
return fmt.Errorf("invalid ADMIN_ENABLED %q", *v)
}
c.Admin.Enabled = b
}
return nil
}
func splitList(v string) []string {
out := []string{}
for _, p := range strings.Split(v, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func (c *Config) Validate() error {
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("server.port out of range: %d", c.Server.Port)
}
if c.Admin.Enabled && (c.Admin.Port < 1 || c.Admin.Port > 65535) {
return fmt.Errorf("admin.port out of range: %d", c.Admin.Port)
}
if c.Admin.Enabled && c.Admin.Port == c.Server.Port {
return errors.New("admin.port must differ from server.port")
}
if c.Upstream.Timeout <= 0 {
return errors.New("upstream.timeout must be > 0")
}
if c.Upstream.StreamTimeout <= 0 {
return errors.New("upstream.stream_timeout must be > 0")
}
if c.Upstream.ContinueRounds < 0 {
c.Upstream.ContinueRounds = 0
}
if err := c.validateTurnstile(); err != nil {
return err
}
if len(c.Models) == 0 {
return errors.New("no models configured")
}
seen := map[string]bool{}
for _, k := range c.Security.APIKeys {
if seen[k] {
return fmt.Errorf("duplicate api key %q", k)
}
seen[k] = true
}
return nil
}
// validateTurnstile checks the token provider settings while the solver is on.
// provider = "manual" needs no credentials — cookies are imported by hand.
func (c *Config) validateTurnstile() error {
if !c.Turnstile.Enabled {
return nil
}
switch c.Turnstile.ProviderValue() {
case ProviderAPI:
if c.Turnstile.APIURL == "" {
return errors.New("turnstile.api_url is empty (set config to your solver endpoint, or use provider = \"manual\")")
}
if c.Turnstile.APIKey == "" {
return errors.New("turnstile.api_key is empty (set config, or TURNSTILE_API_KEY)")
}
case ProviderBrowser:
path := strings.TrimSpace(c.Turnstile.BrowserPath)
if path == "" {
return errors.New("turnstile.browser_path is empty (path to Chrome/Edge, or use provider = \"manual\")")
}
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("turnstile.browser_path %q: %w", path, err)
}
case ProviderManual:
// nothing to check — cookies arrive via the console import box
default:
return fmt.Errorf("turnstile.provider %q is invalid (api|browser|manual)", c.Turnstile.Provider)
}
return nil
}
func (c *Config) RLock() { c.mu.RLock() }
func (c *Config) RUnlock() { c.mu.RUnlock() }
func (c *Config) Lock() { c.mu.Lock() }
func (c *Config) Unlock() { c.mu.Unlock() }
func (c *Config) Path() string { return c.path }
// Site returns a site by code (must hold at least a read lock).
func (c *Config) Site(code string) (*Site, bool) {
s, ok := c.Sites[code]
return s, ok
}
// Model returns a model by id (must hold at least a read lock).
func (c *Config) Model(id string) (*Model, bool) {
m, ok := c.Models[id]
return m, ok
}
// Save writes the config back to its file after taking a timestamped backup.
func (c *Config) Save() error {
return c.SaveTo(c.path)
}
func (c *Config) SaveTo(path string) error {
if path == "" {
return errors.New("config path is empty")
}
if _, err := os.Stat(path); err == nil {
backup := path + ".bak"
if raw, err := os.ReadFile(path); err == nil {
_ = os.WriteFile(backup, raw, 0o644)
}
}
if dir := filepath.Dir(path); dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0o755)
}
var sb strings.Builder
enc := toml.NewEncoder(&sb)
enc.Indent = ""
if err := enc.Encode(c); err != nil {
return fmt.Errorf("encode config: %w", err)
}
header := "# dsfree2api configuration\n# Managed by the web console; a .bak copy is kept next to this file.\n\n"
return os.WriteFile(path, []byte(header+sb.String()), 0o644)
}
// DataDir resolves (and creates) the runtime data directory.
func (c *Config) DataDir() (string, error) {
dir := c.Runtime.DataDir
if dir == "" {
dir = "./data"
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}