| package proxy |
|
|
| import ( |
| "crypto/rand" |
| "crypto/sha256" |
| "encoding/hex" |
| "fmt" |
| "log" |
| "os" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "gopkg.in/yaml.v3" |
|
|
| "notion-manager/internal/netutil" |
| ) |
|
|
| |
| |
| type Config struct { |
| Server ServerConfig `yaml:"server"` |
| Proxy ProxyConfig `yaml:"proxy"` |
| Timeouts TimeoutConfig `yaml:"timeouts"` |
| Refresh RefreshConfig `yaml:"refresh"` |
| Browser BrowserConfig `yaml:"browser"` |
| Register RegisterConfig `yaml:"register"` |
| ModelMap map[string]string `yaml:"model_map"` |
| } |
|
|
| type ServerConfig struct { |
| Port string `yaml:"port"` |
| AccountsDir string `yaml:"accounts_dir"` |
| TokenFile string `yaml:"token_file"` |
| ApiKey string `yaml:"api_key"` |
| AdminPassword string `yaml:"admin_password"` |
| LogFile string `yaml:"log_file"` |
| DebugLogging bool `yaml:"debug_logging"` |
| APILogInput bool `yaml:"api_log_input"` |
| APILogOutput bool `yaml:"api_log_output"` |
| NotionLogReq bool `yaml:"notion_log_request"` |
| NotionLogResp bool `yaml:"notion_log_response"` |
| DumpAPIInput bool `yaml:"dump_api_input"` |
| } |
|
|
| type ProxyConfig struct { |
| NotionAPIBase string `yaml:"notion_api_base"` |
| ClientVersion string `yaml:"client_version"` |
| DefaultModel string `yaml:"default_model"` |
| DisableNotionPrompt bool `yaml:"disable_notion_prompt"` |
| EnableWebSearch *bool `yaml:"enable_web_search"` |
| EnableWorkspaceSearch *bool `yaml:"enable_workspace_search"` |
| |
| |
| |
| |
| |
| AskModeDefault *bool `yaml:"ask_mode_default"` |
| |
| |
| |
| |
| |
| NotionProxy string `yaml:"notion_proxy"` |
| } |
|
|
| type TimeoutConfig struct { |
| InferenceTimeout int `yaml:"inference_timeout"` |
| ResearchTimeout int `yaml:"research_timeout"` |
| APITimeout int `yaml:"api_timeout"` |
| TLSDialTimeout int `yaml:"tls_dial_timeout"` |
| } |
|
|
| type RefreshConfig struct { |
| IntervalMinutes int `yaml:"interval_minutes"` |
| QuotaRecheckMinutes int `yaml:"quota_recheck_minutes"` |
| Concurrency int `yaml:"concurrency"` |
| |
| |
| |
| |
| |
| LiveCheckSeconds int `yaml:"live_check_seconds"` |
| } |
|
|
| type BrowserConfig struct { |
| UserAgent string `yaml:"user_agent"` |
| SecChUA string `yaml:"sec_ch_ua"` |
| SecChUAPlatform string `yaml:"sec_ch_ua_platform"` |
| } |
|
|
| |
| |
| |
| type RegisterConfig struct { |
| |
| |
| |
| HistoryFile string `yaml:"history_file"` |
| |
| |
| HistoryMemoryCap int `yaml:"history_memory_cap"` |
| |
| |
| DefaultConcurrency int `yaml:"default_concurrency"` |
| } |
|
|
| |
| var AppConfig *Config |
|
|
| |
| func DefaultConfig() *Config { |
| return &Config{ |
| Server: ServerConfig{ |
| Port: "8081", |
| AccountsDir: "accounts", |
| TokenFile: "token.txt", |
| ApiKey: "", |
| LogFile: "", |
| DebugLogging: true, |
| APILogInput: false, |
| APILogOutput: false, |
| NotionLogReq: false, |
| NotionLogResp: false, |
| }, |
| Proxy: ProxyConfig{ |
| NotionAPIBase: "https://www.notion.so/api/v3", |
| ClientVersion: "23.13.20260313.1423", |
| DefaultModel: "opus-4.6", |
| EnableWebSearch: boolPtr(true), |
| EnableWorkspaceSearch: boolPtr(false), |
| AskModeDefault: boolPtr(false), |
| }, |
| Timeouts: TimeoutConfig{ |
| InferenceTimeout: 300, |
| ResearchTimeout: 360, |
| APITimeout: 30, |
| TLSDialTimeout: 30, |
| }, |
| Refresh: RefreshConfig{ |
| IntervalMinutes: 30, |
| QuotaRecheckMinutes: 30, |
| Concurrency: 10, |
| LiveCheckSeconds: 5, |
| }, |
| Browser: BrowserConfig{ |
| UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", |
| SecChUA: `"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"`, |
| SecChUAPlatform: `"Windows"`, |
| }, |
| Register: RegisterConfig{ |
| HistoryFile: "accounts/.register_history.json", |
| HistoryMemoryCap: 100, |
| DefaultConcurrency: 1, |
| }, |
| ModelMap: map[string]string{ |
| "opus-4.6": "avocado-froyo-medium", |
| "sonnet-4.6": "almond-croissant-low", |
| "haiku-4.5": "anthropic-haiku-4.5", |
| "gpt-5.2": "oatmeal-cookie", |
| "gpt-5.4": "oval-kumquat-medium", |
| "gemini-2.5-flash": "vertex-gemini-2.5-flash", |
| "gemini-3-flash": "gingerbread", |
| "minimax-m2.5": "fireworks-minimax-m2.5", |
| }, |
| } |
| } |
|
|
| |
| |
| func LoadConfig(configPath string) (*Config, error) { |
| cfg := DefaultConfig() |
| loadedFromFile := false |
| missingConfig := false |
|
|
| |
| if configPath != "" { |
| data, err := os.ReadFile(configPath) |
| if err == nil { |
| loadedFromFile = true |
| if err := yaml.Unmarshal(data, cfg); err != nil { |
| return nil, fmt.Errorf("parse config.yaml: %w", err) |
| } |
| |
| if cfg.ModelMap == nil { |
| cfg.ModelMap = DefaultConfig().ModelMap |
| } |
| } else if !os.IsNotExist(err) { |
| return nil, fmt.Errorf("read config.yaml: %w", err) |
| } else { |
| missingConfig = true |
| } |
| } |
|
|
| |
| if v := os.Getenv("PORT"); v != "" { |
| cfg.Server.Port = v |
| } |
| if v := os.Getenv("ACCOUNTS_DIR"); v != "" { |
| cfg.Server.AccountsDir = v |
| } |
| if v := os.Getenv("TOKEN_FILE"); v != "" { |
| cfg.Server.TokenFile = v |
| } |
| if v := os.Getenv("API_KEY"); v != "" { |
| cfg.Server.ApiKey = v |
| } |
| if v := os.Getenv("ADMIN_PASSWORD"); v != "" { |
| cfg.Server.AdminPassword = v |
| } |
| if v := os.Getenv("LOG_FILE"); v != "" { |
| cfg.Server.LogFile = v |
| } |
| if v := os.Getenv("DEBUG_LOGGING"); v != "" { |
| cfg.Server.DebugLogging = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("API_LOG_INPUT"); v != "" { |
| cfg.Server.APILogInput = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("API_LOG_OUTPUT"); v != "" { |
| cfg.Server.APILogOutput = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("NOTION_LOG_REQUEST"); v != "" { |
| cfg.Server.NotionLogReq = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("NOTION_LOG_RESPONSE"); v != "" { |
| cfg.Server.NotionLogResp = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("DUMP_API_INPUT"); v != "" { |
| cfg.Server.DumpAPIInput = strings.EqualFold(v, "true") || v == "1" |
| } |
| if v := os.Getenv("NOTION_API_BASE"); v != "" { |
| cfg.Proxy.NotionAPIBase = v |
| } |
| if v := os.Getenv("CLIENT_VERSION"); v != "" { |
| cfg.Proxy.ClientVersion = v |
| } |
| if v := os.Getenv("DEFAULT_MODEL"); v != "" { |
| cfg.Proxy.DefaultModel = v |
| } |
| if v := os.Getenv("INFERENCE_TIMEOUT"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil { |
| cfg.Timeouts.InferenceTimeout = n |
| } |
| } |
| if v := os.Getenv("API_TIMEOUT"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil { |
| cfg.Timeouts.APITimeout = n |
| } |
| } |
| if v := os.Getenv("TLS_DIAL_TIMEOUT"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil { |
| cfg.Timeouts.TLSDialTimeout = n |
| } |
| } |
| if v := os.Getenv("REFRESH_INTERVAL_MINUTES"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil { |
| cfg.Refresh.IntervalMinutes = n |
| } |
| } |
| if v := os.Getenv("QUOTA_RECHECK_MINUTES"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil { |
| cfg.Refresh.QuotaRecheckMinutes = n |
| } |
| } |
| if v := os.Getenv("REFRESH_CONCURRENCY"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil && n > 0 { |
| cfg.Refresh.Concurrency = n |
| } |
| } |
| if v := os.Getenv("QUOTA_LIVE_CHECK_SECONDS"); v != "" { |
| if n, err := strconv.Atoi(v); err == nil && n >= 0 { |
| cfg.Refresh.LiveCheckSeconds = n |
| } |
| } |
| if v := os.Getenv("USER_AGENT"); v != "" { |
| cfg.Browser.UserAgent = v |
| } |
| if v := os.Getenv("SEC_CH_UA"); v != "" { |
| cfg.Browser.SecChUA = v |
| } |
| if v := os.Getenv("SEC_CH_UA_PLATFORM"); v != "" { |
| cfg.Browser.SecChUAPlatform = v |
| } |
| if v := os.Getenv("ENABLE_WEB_SEARCH"); v != "" { |
| b := strings.EqualFold(v, "true") || v == "1" |
| cfg.Proxy.EnableWebSearch = &b |
| } |
| if v := os.Getenv("ENABLE_WORKSPACE_SEARCH"); v != "" { |
| b := strings.EqualFold(v, "true") || v == "1" |
| cfg.Proxy.EnableWorkspaceSearch = &b |
| } |
| if v := os.Getenv("ASK_MODE_DEFAULT"); v != "" { |
| b := strings.EqualFold(v, "true") || v == "1" |
| cfg.Proxy.AskModeDefault = &b |
| } |
| if v := os.Getenv("NOTION_PROXY"); v != "" { |
| cfg.Proxy.NotionProxy = strings.TrimSpace(v) |
| } |
| |
| |
| |
| if cfg.Proxy.NotionProxy != "" { |
| if err := netutil.ValidateProxyURL(cfg.Proxy.NotionProxy); err != nil { |
| log.Printf("[config] invalid notion_proxy %q: %v (falling back to direct dial)", cfg.Proxy.NotionProxy, err) |
| cfg.Proxy.NotionProxy = "" |
| } |
| } |
|
|
| |
| if cfg.Register.HistoryFile == "" { |
| cfg.Register.HistoryFile = "accounts/.register_history.json" |
| } |
| if cfg.Register.HistoryMemoryCap <= 0 { |
| cfg.Register.HistoryMemoryCap = 100 |
| } |
| if cfg.Register.DefaultConcurrency <= 0 { |
| cfg.Register.DefaultConcurrency = 1 |
| } |
|
|
| if err := ConfigureLogOutput(cfg.Server.LogFile); err != nil { |
| return nil, fmt.Errorf("configure log output %q: %w", cfg.Server.LogFile, err) |
| } |
|
|
| AppConfig = cfg |
| SetDebugLoggingEnabled(cfg.Server.DebugLogging) |
| SetAPILogInputEnabled(cfg.Server.APILogInput) |
| SetAPILogOutputEnabled(cfg.Server.APILogOutput) |
| SetNotionRequestLoggingEnabled(cfg.Server.NotionLogReq) |
| SetNotionResponseLoggingEnabled(cfg.Server.NotionLogResp) |
| if loadedFromFile { |
| log.Printf("[config] loaded from %s", configPath) |
| } else if missingConfig { |
| log.Printf("[config] %s not found, using defaults", configPath) |
| } |
| return cfg, nil |
| } |
|
|
| |
| func GenerateApiKey() string { |
| b := make([]byte, 16) |
| _, _ = rand.Read(b) |
| return "sk-" + hex.EncodeToString(b) |
| } |
|
|
| |
| func GenerateAdminPassword() string { |
| const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" |
| b := make([]byte, 16) |
| _, _ = rand.Read(b) |
| for i := range b { |
| b[i] = charset[int(b[i])%len(charset)] |
| } |
| return string(b) |
| } |
|
|
| |
| |
| func AdminPasswordSalt(hashed string) string { |
| parts := strings.SplitN(hashed, "$", 4) |
| if len(parts) == 4 && parts[1] == "sha256" { |
| return parts[2] |
| } |
| return "" |
| } |
|
|
| |
| func AdminPasswordHash(hashed string) string { |
| parts := strings.SplitN(hashed, "$", 4) |
| if len(parts) == 4 && parts[1] == "sha256" { |
| return parts[3] |
| } |
| return "" |
| } |
|
|
| |
| func IsAdminPasswordHashed(pw string) bool { |
| return strings.HasPrefix(pw, "$sha256$") |
| } |
|
|
| |
| func HashAdminPassword(plaintext string) string { |
| salt := make([]byte, 16) |
| _, _ = rand.Read(salt) |
| saltHex := hex.EncodeToString(salt) |
| h := sha256.Sum256([]byte(saltHex + plaintext)) |
| return "$sha256$" + saltHex + "$" + hex.EncodeToString(h[:]) |
| } |
|
|
| |
| |
| func VerifyAdminPassword(storedHash, clientHash string) bool { |
| expected := AdminPasswordHash(storedHash) |
| return expected != "" && strings.EqualFold(expected, clientHash) |
| } |
|
|
| |
| func EnsureAdminPassword(cfg *Config, configPath string) { |
| if cfg.Server.AdminPassword == "" || IsAdminPasswordHashed(cfg.Server.AdminPassword) { |
| return |
| } |
|
|
| |
| hashed := HashAdminPassword(cfg.Server.AdminPassword) |
| cfg.Server.AdminPassword = hashed |
| log.Printf("[config] admin_password hashed (SHA256+salt)") |
|
|
| |
| if configPath == "" { |
| return |
| } |
| data, err := os.ReadFile(configPath) |
| if err != nil { |
| return |
| } |
| var root yaml.Node |
| if err := yaml.Unmarshal(data, &root); err != nil || root.Kind == 0 { |
| return |
| } |
| if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { |
| mapping := root.Content[0] |
| for i := 0; i < len(mapping.Content)-1; i += 2 { |
| if mapping.Content[i].Value == "server" { |
| serverNode := mapping.Content[i+1] |
| found := false |
| for j := 0; j < len(serverNode.Content)-1; j += 2 { |
| if serverNode.Content[j].Value == "admin_password" { |
| serverNode.Content[j+1].Value = hashed |
| serverNode.Content[j+1].Tag = "!!str" |
| serverNode.Content[j+1].Style = yaml.DoubleQuotedStyle |
| found = true |
| break |
| } |
| } |
| if !found { |
| serverNode.Content = append(serverNode.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: "admin_password"}, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: hashed, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, |
| ) |
| } |
| break |
| } |
| } |
| } |
| out, err := yaml.Marshal(&root) |
| if err != nil { |
| return |
| } |
| os.WriteFile(configPath, out, 0644) |
| } |
|
|
| |
| |
| func EnsureApiKey(cfg *Config, configPath string) { |
| if cfg.Server.ApiKey != "" { |
| return |
| } |
|
|
| cfg.Server.ApiKey = GenerateApiKey() |
| log.Printf("[config] no api_key configured, generated: %s", cfg.Server.ApiKey) |
|
|
| |
| if configPath == "" { |
| return |
| } |
|
|
| |
| var root yaml.Node |
| data, err := os.ReadFile(configPath) |
| if err == nil { |
| if err := yaml.Unmarshal(data, &root); err != nil { |
| log.Printf("[config] failed to parse %s for write-back: %v", configPath, err) |
| return |
| } |
| } |
|
|
| |
| if root.Kind == 0 { |
| minimal := fmt.Sprintf("server:\n api_key: \"%s\"\n", cfg.Server.ApiKey) |
| if err := os.WriteFile(configPath, []byte(minimal), 0644); err != nil { |
| log.Printf("[config] failed to write %s: %v", configPath, err) |
| } |
| return |
| } |
|
|
| |
| if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { |
| mapping := root.Content[0] |
| var serverNode *yaml.Node |
| for i := 0; i < len(mapping.Content)-1; i += 2 { |
| if mapping.Content[i].Value == "server" { |
| serverNode = mapping.Content[i+1] |
| break |
| } |
| } |
| if serverNode == nil { |
| |
| mapping.Content = append(mapping.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: "server"}, |
| &yaml.Node{Kind: yaml.MappingNode, Content: []*yaml.Node{ |
| {Kind: yaml.ScalarNode, Value: "api_key"}, |
| {Kind: yaml.ScalarNode, Value: cfg.Server.ApiKey, Tag: "!!str"}, |
| }}, |
| ) |
| } else { |
| |
| found := false |
| for i := 0; i < len(serverNode.Content)-1; i += 2 { |
| if serverNode.Content[i].Value == "api_key" { |
| serverNode.Content[i+1].Value = cfg.Server.ApiKey |
| serverNode.Content[i+1].Tag = "!!str" |
| found = true |
| break |
| } |
| } |
| if !found { |
| serverNode.Content = append(serverNode.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: "api_key"}, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: cfg.Server.ApiKey, Tag: "!!str"}, |
| ) |
| } |
| } |
| } |
|
|
| out, err := yaml.Marshal(&root) |
| if err != nil { |
| log.Printf("[config] failed to marshal config: %v", configPath) |
| return |
| } |
| if err := os.WriteFile(configPath, out, 0644); err != nil { |
| log.Printf("[config] failed to write %s: %v", configPath, err) |
| } |
| } |
|
|
| |
|
|
| func (c *Config) InferenceTimeoutDuration() time.Duration { |
| return time.Duration(c.Timeouts.InferenceTimeout) * time.Second |
| } |
|
|
| func (c *Config) ResearchTimeoutDuration() time.Duration { |
| if c.Timeouts.ResearchTimeout <= 0 { |
| return 360 * time.Second |
| } |
| return time.Duration(c.Timeouts.ResearchTimeout) * time.Second |
| } |
|
|
| func (c *Config) APITimeoutDuration() time.Duration { |
| return time.Duration(c.Timeouts.APITimeout) * time.Second |
| } |
|
|
| func (c *Config) TLSDialTimeoutDuration() time.Duration { |
| return time.Duration(c.Timeouts.TLSDialTimeout) * time.Second |
| } |
|
|
| func (c *Config) RefreshInterval() time.Duration { |
| return time.Duration(c.Refresh.IntervalMinutes) * time.Minute |
| } |
|
|
| func (c *Config) QuotaRecheckInterval() time.Duration { |
| return time.Duration(c.Refresh.QuotaRecheckMinutes) * time.Minute |
| } |
|
|
| |
| |
| func (c *Config) QuotaLiveCheckInterval() time.Duration { |
| if c == nil { |
| return 5 * time.Second |
| } |
| if c.Refresh.LiveCheckSeconds < 0 { |
| return 0 |
| } |
| return time.Duration(c.Refresh.LiveCheckSeconds) * time.Second |
| } |
|
|
| |
| func (c *Config) WebSearchEnabled() bool { |
| if c.Proxy.EnableWebSearch == nil { |
| return true |
| } |
| return *c.Proxy.EnableWebSearch |
| } |
|
|
| |
| func (c *Config) WorkspaceSearchEnabled() bool { |
| if c.Proxy.EnableWorkspaceSearch == nil { |
| return false |
| } |
| return *c.Proxy.EnableWorkspaceSearch |
| } |
|
|
| |
| |
| |
| |
| func (c *Config) AskModeDefault() bool { |
| if c == nil || c.Proxy.AskModeDefault == nil { |
| return false |
| } |
| return *c.Proxy.AskModeDefault |
| } |
|
|
| |
| |
| |
| |
| func (c *Config) NotionProxyURL() string { |
| if c == nil { |
| return "" |
| } |
| return strings.TrimSpace(c.Proxy.NotionProxy) |
| } |
|
|
| func boolPtr(b bool) *bool { return &b } |
|
|