MiroFish / internal /proxy /handler.go
pjpjq's picture
fix(space): 强化公开部署的失败关闭与登录保护
2fcb45c verified
Raw
History Blame Contribute Delete
15.8 kB
package proxy
import (
"encoding/json"
"log"
"net/http"
"os"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"notion-manager/internal/netutil"
)
// Default and maximum page sizes for /admin/accounts pagination.
// Default is small to keep dashboard payloads quick; the cap prevents
// callers from accidentally requesting the whole pool through the
// paginated path.
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"`
}
// HandleHealth returns an HTTP handler for the /health endpoint
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)
}
}
// HandlePublicModels returns an OpenAI-compatible models list for API clients.
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]
}
// HandleAdminAccounts returns detailed account info including models, quota, and status.
//
// Query parameters (all optional, dashboard-friendly):
// - q : case-insensitive substring filter on email/name/plan/space.
// - page : 0-based page index. Defaults to 0.
// - page_size : max entries to return; clamped to [1, maxAccountsPageSize].
//
// When ANY of those parameters are present we apply the same sort the
// dashboard previously did client-side, filter, then slice — and add
// `page`, `page_size`, `filtered_total` fields to the response. Without
// them the response keeps its historical shape (the full unsorted list)
// so older scripts and integrations remain happy. The pool-wide
// `summary` block is added unconditionally because it's purely additive
// and lets the dashboard render headline cards without iterating the
// full account list.
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 {
// Backward-compatible path: hand back the full unsorted
// list so existing scripts/integrations keep working.
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)
}
}
// HandleAdminStats returns aggregated Token usage statistics for the
// dashboard. It only requires a valid dashboard session — same auth
// surface as /admin/accounts. The response shape is documented on
// UsageSnapshot.
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)
}
}
// HandleAdminRefresh handles GET (status) and POST (trigger) for quota refresh
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)
}
}
}
// HandleAdminModels returns the current model mapping (friendly name -> Notion internal ID)
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)
}
}
// HandleAdminSettings handles GET (read) and PUT (update) for dashboard-controlled settings.
// Settings are persisted to config.yaml using YAML node manipulation to preserve comments.
func HandleAdminSettings(configPath string, auth *DashboardAuth) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Require dashboard session (admin password auth)
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 {
// Surface scheme/format errors immediately so the
// dashboard can roll back the input field instead
// of waiting for the next dial to fail.
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)
}
}
}
// Persist to config.yaml
if changed && configPath != "" {
persistSearchSettings(configPath)
}
// Drop idle pooled connections so the next notion dial picks
// up the new upstream proxy. Active in-flight requests
// continue on their existing connection until completion.
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)
}
}
}
// persistSearchSettings writes the current dashboard settings back to config.yaml.
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
}
// setYAMLBool sets or creates a boolean field in a YAML mapping 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
}
}
// Key not found — append it
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: key},
&yaml.Node{Kind: yaml.ScalarNode, Value: valStr, Tag: "!!bool"},
)
}
// setYAMLString sets or creates a string field in a YAML mapping node.
// Empty values are still written explicitly so the dashboard's "clear
// proxy" action persists across restarts (otherwise YAML would treat a
// missing key as "use default", which here is also "" — equivalent in
// practice but unfriendly for diffs/audits).
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},
)
}
// isFreePlan returns true if the account is on a free plan where basic credits (200 lifetime)
// never reset. Paid plans (plus, business, enterprise) have monthly premium credits that reset.
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:
// For team plans, check if they actually have a paid subscription
// by looking at quota info — if no premium credits exist, treat as free.
if quota != nil && !quota.HasPremium {
return true
}
return false
}
}