Files
sub2api-add/plugins/subscription-admin/main.go
T
Qiufeng ada4ab3c21
Business Plugins CI / check (plugin-admin) (push) Successful in 1m42s
Business Plugins CI / check (subscription-admin) (push) Successful in 1m30s
feat: complete unified plugin admin v1.1.0
2026-08-30 12:10:04 +08:00

1316 lines
41 KiB
Go

// Command subscription-admin is the standalone read-only subscription business plugin.
// It deliberately uses net/http and the Core public HTTP contract; it does not import
// Core internal packages or the .s2plugin transport ABI.
package main
import (
"bytes"
"context"
"crypto/rand"
"embed"
"encoding/hex"
"encoding/json"
"errors"
"html"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
const (
sessionCookieName = "subscription_admin_session"
maxBodyBytes = 1 << 20
coreRequestLimit = 4 << 20
sessionTTL = 30 * time.Minute
sessionMaxTTL = 8 * time.Hour
pendingTTL = 5 * time.Minute
maxPendingLogins = 1024
pluginID = "qiu.subscription-admin"
pluginVersion = "0.2.0"
requestIDHeader = "X-Request-ID"
maxRequestIDBytes = 64
)
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`)
//go:embed ui/*
var uiFS embed.FS
type coreClient struct {
base string
http *http.Client
}
func newCoreClient(base string) (*coreClient, error) {
base = strings.TrimRight(strings.TrimSpace(base), "/")
u, err := url.Parse(base)
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
return nil, errors.New("CORE_BASE_URL must be an absolute origin URL without credentials, path, query, or fragment")
}
if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) {
return nil, errors.New("CORE_BASE_URL must use HTTPS unless Core is on loopback")
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
return &coreClient{
base: base,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: transport,
// Core API calls must never follow a redirect to an untrusted host.
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse },
},
}, nil
}
func isLoopbackHost(host string) bool {
if strings.EqualFold(strings.TrimSuffix(host, "."), "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
type coreEnvelope struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
status int
}
type coreError struct {
status int
}
func (e *coreError) Error() string {
return "core request failed"
}
// call is the only Core HTTP boundary. Paths are selected by typed methods below,
// never from a browser-supplied URL.
func (c *coreClient) call(ctx context.Context, method, requestPath string, body any, accessToken, correlationID, clientIP string) (coreEnvelope, error) {
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
return coreEnvelope{}, err
}
reader = strings.NewReader(string(payload))
}
req, err := http.NewRequestWithContext(ctx, method, c.base+requestPath, reader)
if err != nil {
return coreEnvelope{}, err
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
req.Header.Set(requestIDHeader, normalizeRequestID(correlationID))
if clientIP != "" {
req.Header.Set("X-Forwarded-For", clientIP)
req.Header.Set("X-Real-IP", clientIP)
}
res, err := c.http.Do(req)
if err != nil {
return coreEnvelope{}, err
}
defer res.Body.Close()
var envelope coreEnvelope
if err := json.NewDecoder(io.LimitReader(res.Body, coreRequestLimit)).Decode(&envelope); err != nil {
return coreEnvelope{}, err
}
envelope.status = res.StatusCode
if res.StatusCode >= http.StatusMultipleChoices {
return envelope, &coreError{status: res.StatusCode}
}
if envelope.Code != 0 {
status := envelope.Code
if status < http.StatusBadRequest || status >= 600 {
status = http.StatusBadGateway
}
return envelope, &coreError{status: status}
}
return envelope, nil
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
TurnstileToken string `json:"turnstile_token,omitempty"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
}
func (c *coreClient) login(ctx context.Context, in loginRequest, correlationID, clientIP string) (coreEnvelope, error) {
return c.call(ctx, http.MethodPost, "/api/v1/auth/login", in, "", correlationID, clientIP)
}
func (c *coreClient) login2FA(ctx context.Context, tempToken, code, correlationID, clientIP string) (coreEnvelope, error) {
return c.call(ctx, http.MethodPost, "/api/v1/auth/login/2fa", map[string]string{"temp_token": tempToken, "totp_code": code}, "", correlationID, clientIP)
}
func (c *coreClient) refresh(ctx context.Context, refreshToken, correlationID string) (coreEnvelope, error) {
return c.call(ctx, http.MethodPost, "/api/v1/auth/refresh", map[string]string{"refresh_token": refreshToken}, "", correlationID, "")
}
func (c *coreClient) logout(ctx context.Context, refreshToken, correlationID string) {
if c == nil || refreshToken == "" {
return
}
_, _ = c.call(ctx, http.MethodPost, "/api/v1/auth/logout", map[string]string{"refresh_token": refreshToken}, "", correlationID, "")
}
func (c *coreClient) me(ctx context.Context, accessToken, correlationID string) (coreEnvelope, error) {
return c.call(ctx, http.MethodGet, "/api/v1/auth/me", nil, accessToken, correlationID, "")
}
func (c *coreClient) publicSettings(ctx context.Context, correlationID string) (coreEnvelope, error) {
return c.call(ctx, http.MethodGet, "/api/v1/settings/public", nil, "", correlationID, "")
}
func (c *coreClient) read(ctx context.Context, requestPath, accessToken string, correlationIDs ...string) (coreEnvelope, error) {
correlationID := ""
if len(correlationIDs) > 0 {
correlationID = correlationIDs[0]
}
u, err := url.Parse(requestPath)
if err != nil || u.Host != "" || u.Scheme != "" || !allowedReadPath(u.Path) {
return coreEnvelope{}, errors.New("core path is not in the allowlist")
}
return c.call(ctx, http.MethodGet, u.EscapedPath()+queryString(sanitizeQuery(u.Query())), nil, accessToken, correlationID, "")
}
func queryString(values url.Values) string {
if encoded := values.Encode(); encoded != "" {
return "?" + encoded
}
return ""
}
func allowedReadPath(path string) bool {
parsed, err := url.Parse(path)
if err != nil || parsed.Host != "" || parsed.Scheme != "" || parsed.Fragment != "" {
return false
}
path = parsed.Path
if path == "/api/v1/admin/payment/plans" || path == "/api/v1/admin/subscriptions" {
return true
}
if strings.HasPrefix(path, "/api/v1/admin/subscriptions/") {
return positiveID(strings.TrimPrefix(path, "/api/v1/admin/subscriptions/"))
}
if strings.HasPrefix(path, "/api/v1/admin/users/") {
rest := strings.TrimPrefix(path, "/api/v1/admin/users/")
if positiveID(rest) {
return true
}
parts := strings.Split(rest, "/")
return len(parts) == 2 && positiveID(parts[0]) && parts[1] == "subscriptions"
}
return false
}
func allowedQuery(r *http.Request) string {
if r == nil {
return ""
}
return queryString(sanitizeQuery(r.URL.Query()))
}
func validQuery(query url.Values) bool {
for _, key := range []string{"page", "page_size", "limit", "user_id", "group_id"} {
for _, raw := range query[key] {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
n, err := strconv.ParseUint(value, 10, 63)
if err != nil || n == 0 || ((key == "page_size" || key == "limit") && n > 100) {
return false
}
}
}
return true
}
func sanitizeQuery(query url.Values) url.Values {
allowed := []string{"page", "page_size", "limit", "user_id", "group_id", "status", "platform", "sort_by", "sort_order"}
values := url.Values{}
for _, key := range allowed {
for _, value := range query[key] {
value = strings.TrimSpace(value)
if value == "" || len(value) > 100 {
continue
}
if key == "page" || key == "page_size" || key == "limit" || key == "user_id" || key == "group_id" {
if _, err := strconv.ParseUint(value, 10, 63); err != nil {
continue
}
}
values.Add(key, value)
}
}
return values
}
func positiveID(value string) bool {
n, err := strconv.ParseInt(value, 10, 64)
return err == nil && n > 0
}
type session struct {
accessToken string
refreshToken string
csrfToken string
user map[string]any
createdAt time.Time
lastSeen time.Time
}
type pendingLogin struct {
tempToken string
expires time.Time
clientIP string
}
type auditEvent struct {
Time time.Time `json:"time"`
PluginID string `json:"plugin_id"`
Action string `json:"action"`
Result string `json:"result"`
UserID any `json:"user_id,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
Request string `json:"request_id"`
}
type app struct {
core *coreClient
sessions map[string]session
sessionLocks map[string]*sync.Mutex
pending map[string]pendingLogin
audit []auditEvent
mu sync.Mutex
cookieSecure bool
cookieSameSite http.SameSite
cookiePath string
publicBasePath string
frameAncestors []string
trustProxy bool
loginWindow time.Duration
loginLimit int
loginAttempts map[string]loginAttempt
clock func() time.Time
}
type loginAttempt struct {
started time.Time
count int
}
func newApp(core *coreClient, secure bool) *app {
return newAppWithConfig(core, appConfig{CookieSecure: secure, CookieSameSite: http.SameSiteLaxMode, CookiePath: "/", PublicBasePath: "", FrameAncestors: []string{"'self'"}})
}
type appConfig struct {
CookieSecure bool
CookieSameSite http.SameSite
CookiePath string
PublicBasePath string
FrameAncestors []string
TrustProxy bool
LoginWindow time.Duration
LoginLimit int
}
func newAppWithConfig(core *coreClient, cfg appConfig) *app {
if cfg.CookieSameSite == 0 {
cfg.CookieSameSite = http.SameSiteLaxMode
}
cfg.CookiePath = normalizeCookiePath(cfg.CookiePath)
cfg.PublicBasePath = normalizeBasePath(cfg.PublicBasePath)
if len(cfg.FrameAncestors) == 0 {
cfg.FrameAncestors = []string{"'self'"}
}
if cfg.LoginWindow <= 0 {
cfg.LoginWindow = time.Minute
}
if cfg.LoginLimit <= 0 {
cfg.LoginLimit = 10
}
return &app{
core: core,
sessions: make(map[string]session),
sessionLocks: make(map[string]*sync.Mutex),
pending: make(map[string]pendingLogin),
audit: make([]auditEvent, 0, 200),
cookieSecure: cfg.CookieSecure,
cookieSameSite: cfg.CookieSameSite,
cookiePath: cfg.CookiePath,
publicBasePath: cfg.PublicBasePath,
frameAncestors: append([]string(nil), cfg.FrameAncestors...),
trustProxy: cfg.TrustProxy,
loginWindow: cfg.LoginWindow,
loginLimit: cfg.LoginLimit,
loginAttempts: make(map[string]loginAttempt),
clock: time.Now,
}
}
func normalizeCookiePath(value string) string {
value = strings.TrimSpace(value)
if value == "" || value == "/" {
return "/"
}
clean := path.Clean("/" + strings.Trim(value, "/"))
return clean + "/"
}
func normalizeBasePath(value string) string {
value = strings.TrimSpace(value)
if value == "" || value == "/" {
return ""
}
clean := path.Clean("/" + strings.Trim(value, "/"))
return clean
}
func (a *app) basePath() string {
return a.publicBasePath
}
func (a *app) addAudit(ctx context.Context, action, result string, userID any, resourceID string) {
a.mu.Lock()
defer a.mu.Unlock()
a.audit = append(a.audit, auditEvent{Time: a.clock(), PluginID: pluginID, Action: action, Result: result, UserID: userID, ResourceID: resourceID, Request: requestIDFromContext(ctx)})
if len(a.audit) > 200 {
a.audit = a.audit[len(a.audit)-200:]
}
}
func requestID() string { return token(16) }
type requestIDContextKey struct{}
func normalizeRequestID(value string) string {
value = strings.TrimSpace(value)
if value == "" || len(value) > maxRequestIDBytes || !requestIDPattern.MatchString(value) {
return requestID()
}
return value
}
func requestIDFromContext(ctx context.Context) string {
if ctx != nil {
if value, ok := ctx.Value(requestIDContextKey{}).(string); ok && value != "" {
return value
}
}
return requestID()
}
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
correlationID := normalizeRequestID(r.Header.Get(requestIDHeader))
w.Header().Set(requestIDHeader, correlationID)
ctx := context.WithValue(r.Context(), requestIDContextKey{}, correlationID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func token(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func decodeJSON(r *http.Request, out any) error {
if r == nil || r.Body == nil {
return errors.New("request body is required")
}
defer r.Body.Close()
raw, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
if err != nil {
return err
}
if int64(len(raw)) > maxBodyBytes {
return errors.New("request body exceeds size limit")
}
if len(bytes.TrimSpace(raw)) == 0 {
return errors.New("request body is required")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(out); err != nil {
return err
}
var trailing any
if err := dec.Decode(&trailing); err != io.EOF {
if err == nil {
return errors.New("request body must contain exactly one JSON value")
}
return err
}
return nil
}
func (a *app) writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
func (a *app) sessionFromRequest(r *http.Request) (string, session, bool) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil || cookie.Value == "" {
return "", session{}, false
}
a.mu.Lock()
defer a.mu.Unlock()
s, ok := a.sessions[cookie.Value]
if !ok {
return "", session{}, false
}
now := a.clock()
if now.Sub(s.createdAt) > sessionMaxTTL || now.Sub(s.lastSeen) > sessionTTL {
delete(a.sessions, cookie.Value)
delete(a.sessionLocks, cookie.Value)
return "", session{}, false
}
s.lastSeen = now
a.sessions[cookie.Value] = s
return cookie.Value, s, true
}
func (a *app) setSessionCookie(w http.ResponseWriter, id string, maxAge int) {
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: id, Path: a.cookiePath, HttpOnly: true, Secure: a.cookieSecure, SameSite: a.cookieSameSite, MaxAge: maxAge})
}
func (a *app) allowLoginAttempt(r *http.Request, identity string) bool {
key := trustedClientIPWithConfig(r, a.trustProxy) + "|" + strings.ToLower(strings.TrimSpace(identity))
now := a.clock()
a.mu.Lock()
defer a.mu.Unlock()
// Expire buckets opportunistically so a long-lived development compatibility
// process cannot retain one map entry for every attempted identity forever.
for candidate, attempt := range a.loginAttempts {
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
delete(a.loginAttempts, candidate)
}
}
attempt := a.loginAttempts[key]
if attempt.started.IsZero() || now.Sub(attempt.started) >= a.loginWindow {
attempt = loginAttempt{started: now}
}
if attempt.count >= a.loginLimit {
a.loginAttempts[key] = attempt
return false
}
attempt.count++
a.loginAttempts[key] = attempt
return true
}
// addPendingLogin keeps the optional standalone 2FA compatibility mode
// bounded. A client can create a challenge without completing it, so expired
// entries are removed before enforcing the hard cap.
func (a *app) addPendingLogin(value pendingLogin) (string, bool) {
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if a.clock != nil {
now = a.clock()
}
for id, pending := range a.pending {
if !pending.expires.After(now) {
delete(a.pending, id)
}
}
if len(a.pending) >= maxPendingLogins {
return "", false
}
if a.pending == nil {
a.pending = make(map[string]pendingLogin)
}
id := token(24)
a.pending[id] = value
return id, true
}
func (a *app) clientIP(r *http.Request) string {
return trustedClientIPWithConfig(r, a.trustProxy)
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var in loginRequest
if err := decodeJSON(r, &in); err != nil || strings.TrimSpace(in.Email) == "" || in.Password == "" {
a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid credentials"})
return
}
in.Email = strings.TrimSpace(in.Email)
if !a.allowLoginAttempt(r, in.Email) {
w.Header().Set("Retry-After", "60")
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many login attempts"})
return
}
out, err := a.core.login(r.Context(), in, requestIDFromContext(r.Context()), a.clientIP(r))
if err != nil {
a.addAudit(r.Context(), "login", "core_unavailable", nil, "")
a.coreError(w, err, "core login failed")
return
}
data := envelopeData(out)
if requires, _ := data["requires_2fa"].(bool); requires {
temp, _ := data["temp_token"].(string)
if temp == "" {
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core 2fa challenge missing"})
return
}
pendingID, accepted := a.addPendingLogin(pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)})
if !accepted {
w.Header().Set("Retry-After", "60")
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many pending login challenges"})
return
}
a.writeJSON(w, http.StatusOK, map[string]any{"requires_2fa": true, "pending_token": pendingID})
return
}
a.finishLogin(w, r, data)
}
func (a *app) login2FA(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
var in struct {
PendingToken string `json:"pending_token"`
TotpCode string `json:"totp_code"`
}
if err := decodeJSON(r, &in); err != nil || in.PendingToken == "" || len(in.TotpCode) != 6 {
a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid 2fa request"})
return
}
if !a.allowLoginAttempt(r, in.PendingToken) {
w.Header().Set("Retry-After", "60")
a.writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "too many login attempts"})
return
}
a.mu.Lock()
pending, ok := a.pending[in.PendingToken]
delete(a.pending, in.PendingToken)
a.mu.Unlock()
if !ok || a.clock().After(pending.expires) || (pending.clientIP != "" && pending.clientIP != a.clientIP(r)) {
a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "2fa session expired"})
return
}
out, err := a.core.login2FA(r.Context(), pending.tempToken, in.TotpCode, requestIDFromContext(r.Context()), a.clientIP(r))
if err != nil {
a.coreError(w, err, "2fa verification failed")
return
}
a.finishLogin(w, r, envelopeData(out))
}
func (a *app) finishLogin(w http.ResponseWriter, r *http.Request, data map[string]any) {
access, _ := data["access_token"].(string)
refresh, _ := data["refresh_token"].(string)
if access == "" {
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "core token missing"})
return
}
correlationID := requestIDFromContext(r.Context())
me, err := a.core.me(r.Context(), access, correlationID)
if err != nil {
a.core.logout(r.Context(), refresh, correlationID)
a.addAudit(r.Context(), "login", "admin_verification_failed", nil, "")
a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin verification failed"})
return
}
user := envelopeData(me)
if !isAdmin(user) {
a.core.logout(r.Context(), refresh, correlationID)
a.addAudit(r.Context(), "login", "forbidden", user["id"], "")
a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"})
return
}
now := a.clock()
s := session{accessToken: access, refreshToken: refresh, csrfToken: token(24), user: publicUser(user), createdAt: now, lastSeen: now}
id := token(32)
a.mu.Lock()
a.sessions[id] = s
a.sessionLocks[id] = &sync.Mutex{}
a.mu.Unlock()
a.addAudit(r.Context(), "login", "success", user["id"], "")
a.setSessionCookie(w, id, int(sessionMaxTTL/time.Second))
// The response intentionally contains only the plugin CSRF token and sanitized user.
a.writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrf_token": s.csrfToken, "user": s.user})
}
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if id, s, ok := a.sessionFromRequest(r); ok {
if r.Header.Get("X-CSRF-Token") != s.csrfToken {
a.addAudit(r.Context(), "logout", "csrf_failed", s.user["id"], "")
a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"})
return
}
a.removeSessionByID(id)
a.core.logout(r.Context(), s.refreshToken, requestIDFromContext(r.Context()))
a.addAudit(r.Context(), "logout", "success", s.user["id"], "")
}
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (a *app) authenticate(w http.ResponseWriter, r *http.Request) (string, session, bool, bool) {
id, s, ok := a.sessionFromRequest(r)
if !ok {
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return "", session{}, false, false
}
if a.core == nil {
a.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"})
return "", session{}, false, false
}
if r.Method != http.MethodGet && r.Header.Get("X-CSRF-Token") != s.csrfToken {
a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "csrf validation failed"})
return "", session{}, false, false
}
correlationID := requestIDFromContext(r.Context())
me, err := a.core.me(r.Context(), s.accessToken, correlationID)
if err != nil {
if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized && s.refreshToken != "" {
if refreshedSession, refreshOK := a.refreshSession(r.Context(), id, s); refreshOK {
return id, refreshedSession, true, true
}
}
a.removeSessionIfCurrent(id, s.accessToken)
a.core.logout(r.Context(), s.refreshToken, correlationID)
a.setSessionCookie(w, "", -1)
if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized {
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
} else {
a.coreError(w, err, "core session unavailable")
}
return "", session{}, false, false
}
if !isAdmin(envelopeData(me)) {
a.removeSessionByID(id)
a.core.logout(r.Context(), s.refreshToken, correlationID)
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin role required"})
return "", session{}, false, false
}
return id, s, false, true
}
func (a *app) me(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
_, s, _, ok := a.authenticate(w, r)
if ok {
a.writeJSON(w, http.StatusOK, map[string]any{"user": s.user, "csrf_token": s.csrfToken, "plugin_id": pluginID, "plugin_version": pluginVersion})
}
}
func (a *app) status(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
_, s, _, ok := a.authenticate(w, r)
if !ok {
return
}
a.writeJSON(w, http.StatusOK, map[string]any{
"plugin_id": pluginID,
"plugin_version": pluginVersion,
"mode": "read_only",
"core_base_configured": a.core != nil,
"session_mode": "memory",
"credential_state": "server_managed",
"operator": s.user,
})
}
func (a *app) captchaConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if a.core == nil {
a.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "core unavailable"})
return
}
out, err := a.core.publicSettings(r.Context(), requestIDFromContext(r.Context()))
if err != nil {
a.coreError(w, err, "core settings unavailable")
return
}
data := envelopeData(out)
provider := ""
if enabled, _ := data["geetest_captcha_enabled"].(bool); enabled {
provider = "geetest"
} else if enabled, _ := data["turnstile_enabled"].(bool); enabled {
provider = "turnstile"
} else if enabled, _ := data["tencent_captcha_enabled"].(bool); enabled {
provider = "tencent"
} else if enabled, _ := data["aliyun_captcha_enabled"].(bool); enabled {
provider = "aliyun"
}
a.writeJSON(w, http.StatusOK, map[string]any{
"enabled": provider != "",
"provider": provider,
"geetest_captcha_id": data["geetest_captcha_id"],
"turnstile_site_key": data["turnstile_site_key"],
"tencent_captcha_app_id": data["tencent_captcha_app_id"],
"tencent_captcha_region": data["tencent_captcha_region"],
"aliyun_captcha_scene_id": data["aliyun_captcha_scene_id"],
"aliyun_captcha_prefix": data["aliyun_captcha_prefix"],
})
}
func (a *app) userProxy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/users/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
sessionID, s, refreshed, ok := a.authenticate(w, r)
if !ok {
return
}
correlationID := requestIDFromContext(r.Context())
corePath := "/api/v1/admin/users/" + id
out, err := a.core.read(r.Context(), corePath, s.accessToken, correlationID)
if err != nil {
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized && !refreshed && s.refreshToken != "" {
if next, refreshOK := a.refreshSession(r.Context(), sessionID, s); refreshOK {
s = next
out, err = a.core.read(r.Context(), corePath, next.accessToken, correlationID)
}
}
if err != nil {
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized {
a.removeSessionIfCurrent(sessionID, s.accessToken)
a.core.logout(r.Context(), s.refreshToken, correlationID)
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
return
}
a.coreError(w, err, "user lookup failed")
return
}
}
a.addAudit(r.Context(), "read:/api/v1/admin/users/{id}", "success", s.user["id"], id)
a.writeCoreEnvelope(w, out)
}
func (a *app) readProxy(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
if !validQuery(r.URL.Query()) {
a.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid query parameters"})
return
}
id, s, refreshed, ok := a.authenticate(w, r)
if !ok {
return
}
corePath := path + allowedQuery(r)
correlationID := requestIDFromContext(r.Context())
out, err := a.core.read(r.Context(), corePath, s.accessToken, correlationID)
if err != nil {
if ce, unauthorized := err.(*coreError); unauthorized && ce.status == http.StatusUnauthorized && !refreshed && s.refreshToken != "" {
if refreshedSession, refreshOK := a.refreshSession(r.Context(), id, s); refreshOK {
s = refreshedSession
out, err = a.core.read(r.Context(), corePath, s.accessToken, correlationID)
}
}
if err != nil {
if ce, ok := err.(*coreError); ok && ce.status == http.StatusUnauthorized {
a.removeSessionIfCurrent(id, s.accessToken)
a.setSessionCookie(w, "", -1)
a.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "core session expired"})
return
}
a.coreError(w, err, "core request failed")
return
}
}
a.addAudit(r.Context(), "read:"+path, "success", s.user["id"], resourceIDFromPath(path))
a.writeCoreEnvelope(w, out)
}
}
func (a *app) updateSession(r *http.Request, updated session) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return
}
a.mu.Lock()
if current, ok := a.sessions[cookie.Value]; ok && current.accessToken != updated.accessToken {
// A concurrent request may have refreshed the session already. Preserve
// the newer token pair while still extending its activity timestamp.
current.lastSeen = updated.lastSeen
a.sessions[cookie.Value] = current
} else {
a.sessions[cookie.Value] = updated
}
a.mu.Unlock()
}
func (a *app) refreshSession(ctx context.Context, id string, stale session) (session, bool) {
a.mu.Lock()
lock := a.sessionLocks[id]
a.mu.Unlock()
if lock == nil {
return session{}, false
}
lock.Lock()
defer lock.Unlock()
a.mu.Lock()
current, ok := a.sessions[id]
a.mu.Unlock()
if !ok {
return session{}, false
}
if current.accessToken != stale.accessToken {
return current, true
}
if current.refreshToken == "" {
return session{}, false
}
correlationID := requestIDFromContext(ctx)
refreshed, err := a.core.refresh(ctx, current.refreshToken, correlationID)
if err != nil {
return session{}, false
}
data := envelopeData(refreshed)
candidateRefresh := current.refreshToken
if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" {
candidateRefresh = nextRefresh
}
access, _ := data["access_token"].(string)
if access == "" {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
// Refresh-token rotation is common. Do not commit the replacement until the
// new access token has passed the Core identity and admin-role checks; if a
// check fails, revoke the replacement instead of leaving it live.
nextMe, err := a.core.me(ctx, access, correlationID)
if err != nil || !isAdmin(envelopeData(nextMe)) {
a.core.logout(ctx, candidateRefresh, correlationID)
return session{}, false
}
current.accessToken = access
current.refreshToken = candidateRefresh
current.user = publicUser(envelopeData(nextMe))
current.lastSeen = a.clock()
a.mu.Lock()
latest, exists := a.sessions[id]
if exists && latest.accessToken == stale.accessToken {
a.sessions[id] = current
} else if exists {
current = latest
}
a.mu.Unlock()
return current, exists
}
func (a *app) removeSessionByID(id string) {
a.mu.Lock()
delete(a.sessions, id)
delete(a.sessionLocks, id)
a.mu.Unlock()
}
func (a *app) removeSessionIfCurrent(id, accessToken string) {
a.mu.Lock()
if current, ok := a.sessions[id]; ok && current.accessToken == accessToken {
delete(a.sessions, id)
delete(a.sessionLocks, id)
}
a.mu.Unlock()
}
func trustedClientIPWithConfig(r *http.Request, trustProxy bool) string {
if r == nil {
return ""
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
host = strings.TrimSpace(r.RemoteAddr)
}
peer := net.ParseIP(host)
if trustProxy && peer != nil && peer.IsLoopback() {
for _, candidate := range strings.Split(r.Header.Get("X-Forwarded-For"), ",") {
candidate = strings.TrimSpace(candidate)
if ip := net.ParseIP(candidate); ip != nil {
return ip.String()
}
}
}
if peer != nil {
return peer.String()
}
return ""
}
func resourceIDFromPath(value string) string {
parts := strings.Split(strings.Trim(value, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
if positiveID(parts[i]) {
return parts[i]
}
}
return ""
}
func (a *app) health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
a.writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "plugin_id": pluginID, "version": pluginVersion})
}
func (a *app) ready(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
a.writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "plugin_id": pluginID, "version": pluginVersion})
}
func (a *app) auditLog(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
a.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
return
}
_, s, _, ok := a.authenticate(w, r)
if !ok {
return
}
a.mu.Lock()
items := append([]auditEvent(nil), a.audit...)
a.mu.Unlock()
a.writeJSON(w, http.StatusOK, map[string]any{"items": items, "operator": s.user})
}
func (a *app) static(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
http.Redirect(w, r, a.basePath()+"/admin/", http.StatusPermanentRedirect)
return
}
if r.URL.Path == "/" || r.URL.Path == "/admin/" {
data, err := uiFS.ReadFile("ui/index.html")
if err != nil {
http.Error(w, "ui unavailable", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
base := html.EscapeString(a.basePath())
data = []byte(strings.ReplaceAll(string(data), "__PLUGIN_BASE_PATH__", base))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
return
}
for _, name := range []string{"ui/app.js", "ui/styles.css"} {
if r.URL.Path == "/"+strings.TrimPrefix(name, "ui/") {
data, err := uiFS.ReadFile(name)
if err != nil {
http.NotFound(w, r)
return
}
if strings.HasSuffix(name, ".js") {
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
} else {
w.Header().Set("Content-Type", "text/css; charset=utf-8")
}
_, _ = w.Write(data)
return
}
}
http.NotFound(w, r)
}
// standaloneAuthEnabled is intentionally fail-closed. The module's own
// login/API compatibility surface is useful for local contract tests only;
// production deployments must expose the shared Plugin Admin Shell instead.
func standaloneAuthEnabled() bool {
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_STANDALONE_AUTH")), "true") {
return false
}
if !strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_ENV")), "development") {
return false
}
host := strings.Trim(strings.TrimSpace(os.Getenv("PLUGIN_HOST")), "[]")
if host == "" {
host = "127.0.0.1"
}
return isLoopbackHost(host)
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.health)
mux.HandleFunc("/readyz", a.ready)
// The business module is mounted by Plugin Admin. A standalone compatibility
// API is available only when explicitly opted into for local contract tests;
// production deployments leave it disabled so there is no second login,
// session cookie, or Core-data BFF on the module's own port.
if standaloneAuthEnabled() {
mux.HandleFunc("/login", a.login)
mux.HandleFunc("/login/2fa", a.login2FA)
mux.HandleFunc("/logout", a.logout)
mux.HandleFunc("/api/me", a.me)
mux.HandleFunc("/api/status", a.status)
mux.HandleFunc("/api/captcha-config", a.captchaConfig)
mux.HandleFunc("/api/audit", a.auditLog)
mux.Handle("/api/plans", a.readProxy("/api/v1/admin/payment/plans"))
mux.Handle("/api/subscriptions", a.readProxy("/api/v1/admin/subscriptions"))
mux.HandleFunc("/api/subscriptions/", func(w http.ResponseWriter, r *http.Request) {
id := strings.TrimPrefix(r.URL.Path, "/api/subscriptions/")
if !positiveID(id) || strings.Contains(id, "/") {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/subscriptions/"+id)(w, r)
})
mux.HandleFunc("/api/users/", func(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/users/")
parts := strings.Split(rest, "/")
if len(parts) == 1 {
a.userProxy(w, r)
return
}
if len(parts) != 2 || !positiveID(parts[0]) || parts[1] != "subscriptions" {
http.NotFound(w, r)
return
}
a.readProxy("/api/v1/admin/users/"+parts[0]+"/subscriptions")(w, r)
})
}
mux.HandleFunc("/", a.static)
return requestIDMiddleware(a.securityHeaders(mux))
}
func (a *app) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
ancestors := strings.Join(a.frameAncestors, " ")
if ancestors == "'self'" {
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
}
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors "+ancestors+"; base-uri 'self'; form-action 'self'")
next.ServeHTTP(w, r)
})
}
func envelopeData(envelope coreEnvelope) map[string]any {
var value map[string]any
if len(envelope.Data) > 0 && json.Unmarshal(envelope.Data, &value) == nil && value != nil {
return value
}
return map[string]any{}
}
func isAdmin(user map[string]any) bool {
role, _ := user["role"].(string)
return strings.EqualFold(role, "admin") || strings.EqualFold(role, "administrator")
}
func publicUser(user map[string]any) map[string]any {
allowed := []string{"id", "email", "username", "role", "status"}
out := make(map[string]any, len(allowed))
for _, key := range allowed {
if value, ok := user[key]; ok {
out[key] = value
}
}
return out
}
func (a *app) writeCoreEnvelope(w http.ResponseWriter, envelope coreEnvelope) {
var data any
if len(envelope.Data) > 0 {
if json.Unmarshal(envelope.Data, &data) != nil {
a.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "invalid core response"})
return
}
}
data = sanitizeValue(data)
status := envelope.status
if status == 0 {
status = http.StatusOK
}
a.writeJSON(w, status, map[string]any{"code": envelope.Code, "message": envelope.Message, "data": data})
}
func sanitizeValue(value any) any {
switch typed := value.(type) {
case []any:
out := make([]any, 0, len(typed))
for _, item := range typed {
out = append(out, sanitizeValue(item))
}
return out
case map[string]any:
out := make(map[string]any, len(typed))
for key, item := range typed {
if sensitiveResponseKey(key) {
continue
}
out[key] = sanitizeValue(item)
}
return out
default:
return value
}
}
func sensitiveResponseKey(key string) bool {
key = strings.ToLower(key)
key = strings.NewReplacer("_", "", "-", "", " ", "").Replace(key)
switch key {
case "accesstoken", "refreshtoken", "idtoken", "token", "authorization", "apikey", "xapikey", "password", "passwordhash", "secret", "clientsecret", "privatekey", "signingkey", "cookie", "session":
return true
default:
return false
}
}
func (a *app) coreError(w http.ResponseWriter, err error, fallback string) {
status := http.StatusBadGateway
if ce, ok := err.(*coreError); ok && ce.status >= 400 && ce.status < 500 {
status = ce.status
}
// Deliberately do not relay Core response bodies or token-bearing messages.
a.writeJSON(w, status, map[string]string{"error": fallback})
}
func main() {
host := os.Getenv("PLUGIN_HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("PLUGIN_PORT")
if port == "" {
port = "8091"
}
coreURL := os.Getenv("CORE_BASE_URL")
if coreURL == "" {
coreURL = "http://127.0.0.1:8080"
}
core, err := newCoreClient(coreURL)
if err != nil {
slog.Error("invalid Core URL", "error", err)
os.Exit(2)
}
secure := strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_COOKIE_SECURE")), "true")
if !isLoopbackHost(host) && !secure {
slog.Error("PLUGIN_COOKIE_SECURE must be true when PLUGIN_HOST is not loopback")
os.Exit(2)
}
sameSite, err := parseSameSite(os.Getenv("PLUGIN_COOKIE_SAMESITE"))
if err != nil {
slog.Error("invalid PLUGIN_COOKIE_SAMESITE", "error", err)
os.Exit(2)
}
basePath := normalizeBasePath(os.Getenv("PLUGIN_PUBLIC_BASE_PATH"))
cookiePath := strings.TrimSpace(os.Getenv("PLUGIN_COOKIE_PATH"))
if cookiePath == "" {
cookiePath = basePath
}
if sameSite == http.SameSiteNoneMode && !secure {
slog.Error("PLUGIN_COOKIE_SAMESITE=none requires PLUGIN_COOKIE_SECURE=true")
os.Exit(2)
}
ancestors := parseFrameAncestors(os.Getenv("PLUGIN_FRAME_ANCESTORS"))
a := newAppWithConfig(core, appConfig{
CookieSecure: secure,
CookieSameSite: sameSite,
CookiePath: cookiePath,
PublicBasePath: basePath,
FrameAncestors: ancestors,
TrustProxy: strings.EqualFold(strings.TrimSpace(os.Getenv("PLUGIN_TRUST_PROXY")), "true"),
})
srv := &http.Server{Addr: host + ":" + port, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second}
slog.Info("subscription-admin listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
slog.Error("subscription-admin stopped", "error", err)
os.Exit(1)
}
}
func parseSameSite(value string) (http.SameSite, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "lax":
return http.SameSiteLaxMode, nil
case "strict":
return http.SameSiteStrictMode, nil
case "none":
return http.SameSiteNoneMode, nil
default:
return 0, errors.New("must be lax, strict, or none")
}
}
func parseFrameAncestors(value string) []string {
fields := strings.Fields(value)
if len(fields) == 0 {
return []string{"'self'"}
}
valid := make([]string, 0, len(fields))
for _, field := range fields {
if field == "'self'" || field == "'none'" {
valid = append(valid, field)
continue
}
u, err := url.Parse(field)
if err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != "" && u.Path == "" && u.RawQuery == "" && u.Fragment == "" && u.User == nil {
valid = append(valid, field)
}
}
if len(valid) == 0 {
return []string{"'self'"}
}
return valid
}