mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
@@ -16,8 +16,8 @@ var Errno = map[int]string{
|
||||
10011: "exceeded maximum sub-order limit",
|
||||
10012: "cannot switch network on a sub-order",
|
||||
10013: "order is not awaiting payment",
|
||||
10014: "supported asset already exists",
|
||||
10015: "supported asset not found",
|
||||
10014: "chain is not enabled",
|
||||
10016: "supported asset not found",
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -36,8 +36,8 @@ var (
|
||||
SubOrderLimitExceeded = Err(10011)
|
||||
CannotSwitchSubOrder = Err(10012)
|
||||
OrderNotWaitPay = Err(10013)
|
||||
SupportedAssetAlreadyExists = Err(10014)
|
||||
SupportedAssetNotFound = Err(10015)
|
||||
ChainNotEnabled = Err(10014)
|
||||
SupportedAssetNotFound = Err(10016)
|
||||
)
|
||||
|
||||
type RspError struct {
|
||||
@@ -60,3 +60,19 @@ func Err(code int) (err error) {
|
||||
func (re *RspError) Render() (code int, msg string) {
|
||||
return re.Code, re.Msg
|
||||
}
|
||||
|
||||
// HttpStatus maps a RspError code to the HTTP status the handler
|
||||
// should use on the wire. Small codes (< 1000) are treated as real
|
||||
// HTTP status codes (e.g. 400 system error, 401 signature failure) so
|
||||
// clients see the right status. Business codes (>= 1000) are all
|
||||
// client-side problems that map to HTTP 400; the specific code still
|
||||
// lives in the body's `status_code` field for the frontend to branch on.
|
||||
func (re *RspError) HttpStatus() int {
|
||||
if re == nil {
|
||||
return 500
|
||||
}
|
||||
if re.Code >= 400 && re.Code < 600 {
|
||||
return re.Code
|
||||
}
|
||||
return 400
|
||||
}
|
||||
|
||||
@@ -58,16 +58,21 @@ func (r *Resp) SucJsonPage(e echo.Context, data interface{}, pagination page.Pag
|
||||
return r.SucJson(e, pageDate, message...)
|
||||
}
|
||||
|
||||
// FailJson 失败json
|
||||
// FailJson 失败json — Propagates semantic HTTP status codes to the
|
||||
// wire. RspError.Code in the 4xx/5xx range is used directly; business
|
||||
// codes (>=1000) map to HTTP 400 with the specific code still visible
|
||||
// in the body's status_code field.
|
||||
func (r *Resp) FailJson(e echo.Context, err error) error {
|
||||
rr := new(Response)
|
||||
switch err.(type) {
|
||||
httpStatus := http.StatusBadRequest
|
||||
switch t := err.(type) {
|
||||
case *constant.RspError:
|
||||
rr.StatusCode, rr.Message = err.(*constant.RspError).Render()
|
||||
rr.StatusCode, rr.Message = t.Render()
|
||||
httpStatus = t.HttpStatus()
|
||||
default:
|
||||
rr.StatusCode = 400
|
||||
rr.StatusCode = http.StatusBadRequest
|
||||
rr.Message = err.Error()
|
||||
}
|
||||
rr.RequestID = e.Request().Header.Get(echo.HeaderXRequestID)
|
||||
return r.Json(e, http.StatusOK, &rr)
|
||||
return r.Json(e, httpStatus, &rr)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResolveSPAFilePath normalizes a wildcard SPA path and maps it under wwwRoot.
|
||||
// It strips any leading slash and blocks path traversal outside wwwRoot.
|
||||
// The second return value indicates whether the caller should try os.Stat
|
||||
// against the returned path (true) or directly fall back to index.html (false).
|
||||
func ResolveSPAFilePath(wwwRoot, wildcard string) (string, bool) {
|
||||
indexPath := filepath.Join(wwwRoot, "index.html")
|
||||
|
||||
cleaned := strings.TrimPrefix(filepath.Clean(wildcard), "/")
|
||||
if cleaned == "" || cleaned == "." {
|
||||
return indexPath, false
|
||||
}
|
||||
|
||||
requestedPath := filepath.Join(wwwRoot, cleaned)
|
||||
base := filepath.Clean(wwwRoot)
|
||||
resolved := filepath.Clean(requestedPath)
|
||||
|
||||
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||
return indexPath, false
|
||||
}
|
||||
|
||||
return requestedPath, true
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveSPAFilePath(t *testing.T) {
|
||||
root := filepath.Join("tmp", "www")
|
||||
indexPath := filepath.Join(root, "index.html")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wildcard string
|
||||
wantPath string
|
||||
wantTryStat bool
|
||||
}{
|
||||
{
|
||||
name: "relative asset path",
|
||||
wildcard: "assets/app.js",
|
||||
wantPath: filepath.Join(root, "assets", "app.js"),
|
||||
wantTryStat: true,
|
||||
},
|
||||
{
|
||||
name: "absolute style asset path",
|
||||
wildcard: "/assets/app.js",
|
||||
wantPath: filepath.Join(root, "assets", "app.js"),
|
||||
wantTryStat: true,
|
||||
},
|
||||
{
|
||||
name: "empty wildcard",
|
||||
wildcard: "",
|
||||
wantPath: indexPath,
|
||||
wantTryStat: false,
|
||||
},
|
||||
{
|
||||
name: "dot wildcard",
|
||||
wildcard: ".",
|
||||
wantPath: indexPath,
|
||||
wantTryStat: false,
|
||||
},
|
||||
{
|
||||
name: "directory traversal fallback",
|
||||
wildcard: "../../etc/passwd",
|
||||
wantPath: indexPath,
|
||||
wantTryStat: false,
|
||||
},
|
||||
{
|
||||
name: "absolute directory traversal fallback",
|
||||
wildcard: "/../../etc/passwd",
|
||||
wantPath: filepath.Join(root, "etc", "passwd"),
|
||||
wantTryStat: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotPath, gotTryStat := ResolveSPAFilePath(root, tt.wildcard)
|
||||
|
||||
if gotTryStat != tt.wantTryStat {
|
||||
t.Fatalf("tryStat = %v, want %v", gotTryStat, tt.wantTryStat)
|
||||
}
|
||||
if gotPath != tt.wantPath {
|
||||
t.Fatalf("path = %q, want %q", gotPath, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
// DefaultExpiration is the token lifetime for admin sessions.
|
||||
const DefaultExpiration = 24 * time.Hour
|
||||
|
||||
// AdminClaims is the JWT payload for admin sessions.
|
||||
type AdminClaims struct {
|
||||
AdminUserID uint64 `json:"uid"`
|
||||
Username string `json:"usr"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// EnsureSecret reads system.jwt_secret from settings; if absent,
|
||||
// generates a new 32-byte random hex string and persists it. Called once
|
||||
// at startup so subsequent sign/verify can assume the secret exists.
|
||||
func EnsureSecret() (string, error) {
|
||||
secret := data.GetSettingString(mdb.SettingKeyJwtSecret, "")
|
||||
if secret != "" {
|
||||
return secret, nil
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret = hex.EncodeToString(buf)
|
||||
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, secret, mdb.SettingTypeString); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// Sign returns a signed JWT for the given admin user.
|
||||
func Sign(userID uint64, username string) (string, error) {
|
||||
secret, err := EnsureSecret()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
claims := AdminClaims{
|
||||
AdminUserID: userID,
|
||||
Username: username,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(DefaultExpiration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// Parse validates a token string and returns its claims.
|
||||
func Parse(tokenStr string) (*AdminClaims, error) {
|
||||
secret, err := EnsureSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims := &AdminClaims{}
|
||||
_, err = jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
Reference in New Issue
Block a user