package httpapi import ( "encoding/json" "net/http" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" "github.com/wangjia/pangolin/server/internal/routing" ) // RoutingAPI serves /v1/me/routing: the user's configurable routing profile // (可配置分流). GET returns the stored profile or routing.Default() when the // user hasn't customized one yet; POST validates and upserts. type RoutingAPI struct { store *routing.Store // lockedDomains are the system-forced-tunnel private-service domains // (PANGOLIN_PRIVATE_SPLIT_DOMAINS, same slice injected into NodeAPI) — // read-only, surfaced to GET so clients can warn users that rules against // these domains silently have no effect. Never written to the persisted // Profile. lockedDomains []string } // NewRoutingAPI creates a RoutingAPI backed by the given routing.Store. // lockedDomains is the system-forced private-service domain list (may be nil). func NewRoutingAPI(store *routing.Store, lockedDomains []string) *RoutingAPI { return &RoutingAPI{store: store, lockedDomains: lockedDomains} } // profileResponse wraps routing.Profile for GET /v1/me/routing, adding the // read-only system_locked_domains list. It deliberately lives here — not on // routing.Profile itself — so the field can never leak into the writable // Profile contract that SaveProfile decodes POST bodies into. type profileResponse struct { *routing.Profile SystemLockedDomains []string `json:"system_locked_domains"` } // GetProfile handles GET /v1/me/routing. func (a *RoutingAPI) GetProfile(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } p, err := a.store.Get(r.Context(), uid) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if p == nil { p = routing.Default() } locked := a.lockedDomains if locked == nil { locked = []string{} } writeJSON(w, http.StatusOK, profileResponse{Profile: p, SystemLockedDomains: locked}) } // SaveProfile handles POST /v1/me/routing. On validation failure it returns // 400 with every offending field reported at once (no partial save). func (a *RoutingAPI) SaveProfile(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } var p routing.Profile if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&p); err != nil { apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest) return } p.Normalize() if errs := p.Validate(); len(errs) > 0 { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{ "code": "routing_invalid", "message_zh": "规则校验未通过", "message_en": "Rule validation failed", "errors": errs, }) return } if err := a.store.Upsert(r.Context(), uid, &p); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } writeJSON(w, http.StatusOK, &p) }