// 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 ( "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 pluginID = "qiu.subscription-admin" pluginVersion = "0.1.1" 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 { defer r.Body.Close() dec := json.NewDecoder(io.LimitReader(r.Body, maxBodyBytes)) dec.DisallowUnknownFields() return dec.Decode(out) } 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() 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 } 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 := token(24) a.mu.Lock() a.pending[pendingID] = pendingLogin{tempToken: temp, expires: a.clock().Add(pendingTTL), clientIP: a.clientIP(r)} a.mu.Unlock() 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 { 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) access, _ := data["access_token"].(string) if access == "" { return session{}, false } nextMe, err := a.core.me(ctx, access, correlationID) if err != nil || !isAdmin(envelopeData(nextMe)) { return session{}, false } current.accessToken = access if nextRefresh, ok := data["refresh_token"].(string); ok && nextRefresh != "" { current.refreshToken = nextRefresh } 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) } func (a *app) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", a.health) mux.HandleFunc("/readyz", a.ready) 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 }