Files

93 lines
3.2 KiB
Go

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)
}