1219 lines
38 KiB
Go
1219 lines
38 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 (
|
|
"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
|
|
}
|