package httpapi import ( "context" "crypto/rand" "database/sql" "encoding/json" "fmt" "net/http" "github.com/go-chi/chi/v5" "github.com/wangjia/pangolin/server/internal/apierr" "github.com/wangjia/pangolin/server/internal/auth" "github.com/wangjia/pangolin/server/internal/nodes" ) // SubscriptionAPI serves the per-user subscription URL used by the web // user-center ("订阅导入"). The URL points at GET /sub/{token}, which renders the // user's sing-box client config so it can be imported into a client app. // // GET /v1/me/subscription → {"url": "/sub/"} // POST /v1/me/subscription/reset → rotates the token, returns the new url // GET /sub/{token} → public; sing-box client config for that user type SubscriptionAPI struct { db *sql.DB nodes nodes.NodeStore deriveKey string subBase string // e.g. "https://sub.example.com" (no trailing slash); may be empty } // NewSubscriptionAPI creates a SubscriptionAPI. subBase is the public base URL // the subscription link is built from; when empty the request scheme+host is used. func NewSubscriptionAPI(db *sql.DB, store nodes.NodeStore, deriveKey, subBase string) *SubscriptionAPI { return &SubscriptionAPI{db: db, nodes: store, deriveKey: deriveKey, subBase: subBase} } type subscriptionResponse struct { URL string `json:"url"` } // GetSubscription handles GET /v1/me/subscription. func (s *SubscriptionAPI) GetSubscription(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } token, apiErr := s.ensureToken(r.Context(), uid) if apiErr != nil { apierr.WriteJSON(w, statusFor(apiErr), apiErr) return } writeJSON(w, http.StatusOK, subscriptionResponse{URL: s.urlFor(r, token)}) } // ResetSubscription handles POST /v1/me/subscription/reset — rotates the token. func (s *SubscriptionAPI) ResetSubscription(w http.ResponseWriter, r *http.Request) { uid, ok := auth.UserIDFromContext(r.Context()) if !ok { apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized) return } token, err := newSubToken() if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } if _, err := s.db.ExecContext(r.Context(), `UPDATE users SET sub_token = ? WHERE id = ? AND status = 'active'`, token, uid); err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } writeJSON(w, http.StatusOK, subscriptionResponse{URL: s.urlFor(r, token)}) } // ServeSub handles GET /sub/{token} — public, no JWT. Renders the sing-box client // config for the user owning the token, against the first active node. func (s *SubscriptionAPI) ServeSub(w http.ResponseWriter, r *http.Request) { token := chi.URLParam(r, "token") if token == "" { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) return } var dpUUID string err := s.db.QueryRowContext(r.Context(), `SELECT dp_uuid FROM users WHERE sub_token = ? AND status = 'active'`, token).Scan(&dpUUID) if err == sql.ErrNoRows { apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound) return } else if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } node, apiErr := s.firstActiveNode(r.Context()) if apiErr != nil { apierr.WriteJSON(w, statusFor(apiErr), apiErr) return } // 订阅链接暂不开国内分流(默认 opts);后续可按需加 query/偏好。 cfg, err := BuildClientConfig(node, dpUUID, s.deriveKey, ClientConfigOpts{}) if err != nil { apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(cfg) } // ── helpers ────────────────────────────────────────────────────────────────── // ensureToken returns the user's sub_token, generating and persisting one on // first access. func (s *SubscriptionAPI) ensureToken(ctx context.Context, uid int64) (string, *apierr.Error) { var token sql.NullString if err := s.db.QueryRowContext(ctx, `SELECT sub_token FROM users WHERE id = ? AND status = 'active'`, uid).Scan(&token); err == sql.ErrNoRows { return "", apierr.ErrNotFound } else if err != nil { return "", apierr.ErrInternal } if token.Valid && token.String != "" { return token.String, nil } t, err := newSubToken() if err != nil { return "", apierr.ErrInternal } if _, err := s.db.ExecContext(ctx, `UPDATE users SET sub_token = ? WHERE id = ?`, t, uid); err != nil { return "", apierr.ErrInternal } return t, nil } func (s *SubscriptionAPI) firstActiveNode(ctx context.Context) (*nodes.NodeRow, *apierr.Error) { uuids, err := s.nodes.ActiveNodeUUIDs(ctx) if err != nil || len(uuids) == 0 { return nil, apierr.ErrNotFound } node, err := s.nodes.NodeByUUID(ctx, uuids[0]) if err != nil || node == nil { return nil, apierr.ErrNotFound } return node, nil } func (s *SubscriptionAPI) urlFor(r *http.Request, token string) string { base := s.subBase if base == "" { scheme := "https" if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") == "" { scheme = "http" } base = scheme + "://" + r.Host } return base + "/sub/" + token } // newSubToken returns a random UUIDv4 string (fits users.sub_token CHAR(36)). func newSubToken() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", err } b[6] = (b[6] & 0x0f) | 0x40 // version 4 b[8] = (b[8] & 0x3f) | 0x80 // variant 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } // statusFor maps the small set of apierr values used here to HTTP status codes. func statusFor(e *apierr.Error) int { switch e { case apierr.ErrNotFound: return http.StatusNotFound case apierr.ErrUnauthorized: return http.StatusUnauthorized default: return http.StatusInternalServerError } }