| package proxy |
|
|
| import ( |
| "encoding/json" |
| "log" |
| "net/http" |
| "os" |
| "sort" |
| "strconv" |
| "strings" |
|
|
| "gopkg.in/yaml.v3" |
|
|
| "notion-manager/internal/netutil" |
| ) |
|
|
| |
| |
| |
| |
| const ( |
| defaultAccountsPageSize = 50 |
| maxAccountsPageSize = 500 |
| ) |
|
|
| const publicModelCreatedAt = int64(1735689600) |
|
|
| type publicModelResponse struct { |
| Object string `json:"object"` |
| Data []publicModel `json:"data"` |
| } |
|
|
| type publicModel struct { |
| ID string `json:"id"` |
| Object string `json:"object"` |
| Created int64 `json:"created"` |
| OwnedBy string `json:"owned_by"` |
| } |
|
|
| |
| func HandleHealth(pool *AccountPool) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| status := "ok" |
| statusCode := http.StatusOK |
| if pool.Count() == 0 || pool.AvailableCount() == 0 { |
| status = "unavailable" |
| statusCode = http.StatusServiceUnavailable |
| } |
| resp := map[string]interface{}{ |
| "status": status, |
| "accounts": pool.Count(), |
| "available": pool.AvailableCount(), |
| "quota": pool.GetQuotaSummary(), |
| } |
| w.WriteHeader(statusCode) |
| json.NewEncoder(w).Encode(resp) |
| } |
| } |
|
|
| |
| func HandlePublicModels(pool *AccountPool) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| if r.Method != http.MethodGet { |
| w.Header().Set("Allow", http.MethodGet) |
| http.Error(w, `{"error":{"message":"method not allowed","type":"invalid_request_error"}}`, http.StatusMethodNotAllowed) |
| return |
| } |
|
|
| resp := publicModelResponse{ |
| Object: "list", |
| Data: buildPublicModels(pool.AllModels()), |
| } |
| json.NewEncoder(w).Encode(resp) |
| } |
| } |
|
|
| func buildPublicModels(models []ModelEntry) []publicModel { |
| seen := make(map[string]bool, len(models)) |
| items := make([]publicModel, 0, len(models)) |
| for _, model := range models { |
| id := publicModelID(model) |
| if id == "" || seen[id] { |
| continue |
| } |
| seen[id] = true |
| items = append(items, publicModel{ |
| ID: id, |
| Object: "model", |
| Created: publicModelCreatedAt, |
| OwnedBy: "notion-manager", |
| }) |
| } |
|
|
| sort.Slice(items, func(i, j int) bool { |
| return items[i].ID < items[j].ID |
| }) |
| return items |
| } |
|
|
| func publicModelID(model ModelEntry) string { |
| if normalized := normalizeModelName(model.Name); normalized != "" { |
| return normalized |
| } |
| return friendlyModelNameByInternalID(model.ID) |
| } |
|
|
| func friendlyModelNameByInternalID(id string) string { |
| trimmed := strings.TrimSpace(id) |
| if trimmed == "" { |
| return "" |
| } |
|
|
| snap := SnapshotModelMap() |
| candidates := make([]string, 0, 1) |
| for friendly, internalID := range snap { |
| if internalID == trimmed { |
| candidates = append(candidates, friendly) |
| } |
| } |
| if len(candidates) == 0 { |
| return "" |
| } |
|
|
| sort.Strings(candidates) |
| return candidates[0] |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| func HandleAdminAccounts(pool *AccountPool, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
|
|
| q := strings.TrimSpace(r.URL.Query().Get("q")) |
| pageStr := r.URL.Query().Get("page") |
| sizeStr := r.URL.Query().Get("page_size") |
| paginated := pageStr != "" || sizeStr != "" || q != "" |
|
|
| all := pool.GetAccountDetails() |
| resp := map[string]interface{}{ |
| "total": pool.Count(), |
| "available": pool.AvailableCount(), |
| "models": pool.AllModels(), |
| "refresh": pool.GetRefreshStatus(), |
| "summary": summarizeAccounts(all), |
| } |
|
|
| if !paginated { |
| |
| |
| resp["accounts"] = all |
| json.NewEncoder(w).Encode(resp) |
| return |
| } |
|
|
| filtered := filterAccountDetails(all, q) |
| sortAccountDetails(filtered) |
|
|
| page, _ := strconv.Atoi(pageStr) |
| if page < 0 { |
| page = 0 |
| } |
| size, _ := strconv.Atoi(sizeStr) |
| if size <= 0 { |
| size = defaultAccountsPageSize |
| } |
| if size > maxAccountsPageSize { |
| size = maxAccountsPageSize |
| } |
|
|
| resp["accounts"] = paginateAccounts(filtered, page, size) |
| resp["page"] = page |
| resp["page_size"] = size |
| resp["filtered_total"] = len(filtered) |
| json.NewEncoder(w).Encode(resp) |
| } |
| } |
|
|
| |
| |
| |
| |
| func HandleAdminStats(stats *UsageStats, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
| if stats == nil { |
| stats = GlobalUsageStats() |
| } |
| snap := stats.Snapshot(5) |
| json.NewEncoder(w).Encode(snap) |
| } |
| } |
|
|
| |
| func HandleAdminRefresh(pool *AccountPool, accountsDir string, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
| switch r.Method { |
| case "GET": |
| json.NewEncoder(w).Encode(pool.GetRefreshStatus()) |
| case "POST": |
| started := pool.TriggerRefresh(accountsDir) |
| resp := map[string]interface{}{ |
| "started": started, |
| } |
| if !started { |
| resp["message"] = "refresh already in progress" |
| } |
| json.NewEncoder(w).Encode(resp) |
| default: |
| http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) |
| } |
| } |
| } |
|
|
| |
| func HandleAdminModels(pool *AccountPool, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
| resp := map[string]interface{}{ |
| "model_map": SnapshotModelMap(), |
| "available_models": pool.AllModels(), |
| } |
| json.NewEncoder(w).Encode(resp) |
| } |
| } |
|
|
| |
| |
| func HandleAdminSettings(configPath string, auth *DashboardAuth) http.HandlerFunc { |
| return func(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "application/json") |
|
|
| |
| if auth.HasAdminPassword() && !auth.ValidateSession(r) { |
| http.Error(w, `{"error":"unauthorized, dashboard login required"}`, http.StatusUnauthorized) |
| return |
| } |
|
|
| switch r.Method { |
| case "GET": |
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "enable_web_search": AppConfig.WebSearchEnabled(), |
| "enable_workspace_search": AppConfig.WorkspaceSearchEnabled(), |
| "ask_mode_default": AppConfig.AskModeDefault(), |
| "disable_notion_prompt": AppConfig.Proxy.DisableNotionPrompt, |
| "debug_logging": AppConfig.Server.DebugLogging, |
| "notion_proxy": AppConfig.NotionProxyURL(), |
| }) |
|
|
| case "PUT": |
| var body struct { |
| EnableWebSearch *bool `json:"enable_web_search"` |
| EnableWorkspaceSearch *bool `json:"enable_workspace_search"` |
| AskModeDefault *bool `json:"ask_mode_default"` |
| DebugLogging *bool `json:"debug_logging"` |
| NotionProxy *string `json:"notion_proxy"` |
| } |
| if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest) |
| return |
| } |
|
|
| changed := false |
| rebuildTransport := false |
| if body.EnableWebSearch != nil { |
| AppConfig.Proxy.EnableWebSearch = body.EnableWebSearch |
| changed = true |
| log.Printf("[settings] enable_web_search → %v", *body.EnableWebSearch) |
| } |
| if body.EnableWorkspaceSearch != nil { |
| AppConfig.Proxy.EnableWorkspaceSearch = body.EnableWorkspaceSearch |
| changed = true |
| log.Printf("[settings] enable_workspace_search → %v", *body.EnableWorkspaceSearch) |
| } |
| if body.AskModeDefault != nil { |
| AppConfig.Proxy.AskModeDefault = body.AskModeDefault |
| changed = true |
| log.Printf("[settings] ask_mode_default → %v", *body.AskModeDefault) |
| } |
| if body.DebugLogging != nil { |
| AppConfig.Server.DebugLogging = *body.DebugLogging |
| SetDebugLoggingEnabled(*body.DebugLogging) |
| changed = true |
| log.Printf("[settings] debug_logging → %v", *body.DebugLogging) |
| } |
| if body.NotionProxy != nil { |
| next := strings.TrimSpace(*body.NotionProxy) |
| if next != "" { |
| if err := netutil.ValidateProxyURL(next); err != nil { |
| |
| |
| |
| http.Error(w, `{"error":"unsupported proxy scheme (want http/https/socks5)"}`, http.StatusBadRequest) |
| return |
| } |
| } |
| if AppConfig.Proxy.NotionProxy != next { |
| AppConfig.Proxy.NotionProxy = next |
| changed = true |
| rebuildTransport = true |
| if next == "" { |
| log.Printf("[settings] notion_proxy cleared (direct dial)") |
| } else { |
| log.Printf("[settings] notion_proxy → %s", next) |
| } |
| } |
| } |
|
|
| |
| if changed && configPath != "" { |
| persistSearchSettings(configPath) |
| } |
|
|
| |
| |
| |
| if rebuildTransport { |
| RebuildChromeTransport() |
| } |
|
|
| json.NewEncoder(w).Encode(map[string]interface{}{ |
| "enable_web_search": AppConfig.WebSearchEnabled(), |
| "enable_workspace_search": AppConfig.WorkspaceSearchEnabled(), |
| "ask_mode_default": AppConfig.AskModeDefault(), |
| "disable_notion_prompt": AppConfig.Proxy.DisableNotionPrompt, |
| "debug_logging": AppConfig.Server.DebugLogging, |
| "notion_proxy": AppConfig.NotionProxyURL(), |
| }) |
|
|
| default: |
| http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) |
| } |
| } |
| } |
|
|
| |
| func persistSearchSettings(configPath string) { |
| data, err := os.ReadFile(configPath) |
| if err != nil { |
| log.Printf("[settings] failed to read %s: %v", configPath, err) |
| return |
| } |
| var root yaml.Node |
| if err := yaml.Unmarshal(data, &root); err != nil || root.Kind == 0 { |
| log.Printf("[settings] failed to parse %s: %v", configPath, err) |
| return |
| } |
|
|
| if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { |
| mapping := root.Content[0] |
| proxyNode := getOrCreateYAMLMapping(mapping, "proxy") |
| setYAMLBool(proxyNode, "enable_web_search", AppConfig.WebSearchEnabled()) |
| setYAMLBool(proxyNode, "enable_workspace_search", AppConfig.WorkspaceSearchEnabled()) |
| setYAMLBool(proxyNode, "ask_mode_default", AppConfig.AskModeDefault()) |
| setYAMLString(proxyNode, "notion_proxy", AppConfig.Proxy.NotionProxy) |
|
|
| serverNode := getOrCreateYAMLMapping(mapping, "server") |
| setYAMLBool(serverNode, "debug_logging", AppConfig.Server.DebugLogging) |
| } |
|
|
| out, err := yaml.Marshal(&root) |
| if err != nil { |
| log.Printf("[settings] failed to marshal config: %v", err) |
| return |
| } |
| if err := os.WriteFile(configPath, out, 0644); err != nil { |
| log.Printf("[settings] failed to write %s: %v", configPath, err) |
| } |
| } |
|
|
| func getOrCreateYAMLMapping(mapping *yaml.Node, key string) *yaml.Node { |
| for i := 0; i < len(mapping.Content)-1; i += 2 { |
| if mapping.Content[i].Value == key { |
| node := mapping.Content[i+1] |
| if node.Kind != yaml.MappingNode { |
| node.Kind = yaml.MappingNode |
| node.Tag = "!!map" |
| node.Content = nil |
| } |
| return node |
| } |
| } |
|
|
| node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} |
| mapping.Content = append(mapping.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: key}, |
| node, |
| ) |
| return node |
| } |
|
|
| |
| func setYAMLBool(mapping *yaml.Node, key string, value bool) { |
| valStr := "false" |
| if value { |
| valStr = "true" |
| } |
| for i := 0; i < len(mapping.Content)-1; i += 2 { |
| if mapping.Content[i].Value == key { |
| mapping.Content[i+1].Value = valStr |
| mapping.Content[i+1].Tag = "!!bool" |
| return |
| } |
| } |
| |
| mapping.Content = append(mapping.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: key}, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: valStr, Tag: "!!bool"}, |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| func setYAMLString(mapping *yaml.Node, key, value string) { |
| style := yaml.Style(0) |
| if value == "" { |
| style = yaml.DoubleQuotedStyle |
| } |
| for i := 0; i < len(mapping.Content)-1; i += 2 { |
| if mapping.Content[i].Value == key { |
| mapping.Content[i+1].Value = value |
| mapping.Content[i+1].Tag = "!!str" |
| mapping.Content[i+1].Style = style |
| return |
| } |
| } |
| mapping.Content = append(mapping.Content, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: key}, |
| &yaml.Node{Kind: yaml.ScalarNode, Value: value, Tag: "!!str", Style: style}, |
| ) |
| } |
|
|
| |
| |
| func isFreePlan(acc *Account) bool { |
| quota := acc.quotaInfoSnapshot() |
| if quota != nil && (quota.HasPremium || quota.PremiumLimit > 0 || quota.PremiumBalance > 0) { |
| return false |
| } |
| switch strings.ToLower(strings.TrimSpace(acc.PlanType)) { |
| case "personal", "free", "": |
| return true |
| default: |
| |
| |
| if quota != nil && !quota.HasPremium { |
| return true |
| } |
| return false |
| } |
| } |
|
|