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:
@@ -0,0 +1,336 @@
|
||||
// Package install provides a first-run setup REST API.
|
||||
//
|
||||
// When the .env config file is absent (or has install=true) the HTTP start
|
||||
// command calls RunInstallServer, which listens on the same address the main
|
||||
// server will eventually use (default :8000) and mounts two JSON endpoints
|
||||
// under /install consumed by the frontend install UI:
|
||||
//
|
||||
// GET /install/defaults — default field values for the form
|
||||
// POST /install — validate + write .env, then shut down
|
||||
//
|
||||
// The HTTP listen address is submitted as two separate fields (http_bind_addr
|
||||
// and http_bind_port) and combined internally as "ADDR:PORT" before writing
|
||||
// the http_listen key in .env. This makes the form easier for users who only
|
||||
// want to change the port without touching the bind address.
|
||||
//
|
||||
// Once the .env is written the install server stops and normal bootstrap
|
||||
// proceeds on the same port without a restart.
|
||||
package install
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/gookit/color"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
// DefaultInstallAddr is the listen address used by the install API.
|
||||
// Matches the default http_listen so no extra port is needed.
|
||||
const DefaultInstallAddr = ":8000"
|
||||
|
||||
// InstallRequest is the payload submitted by the install form.
|
||||
// All fields are optional except AppURI; omitted fields fall back to InstallDefaults().
|
||||
type InstallRequest struct {
|
||||
// Application display name (default: epusdt)
|
||||
AppName string `json:"app_name" form:"app_name" example:"epusdt"`
|
||||
// Public base URL of the service, e.g. https://pay.example.com (required)
|
||||
AppURI string `json:"app_uri" form:"app_uri" example:"https://pay.example.com"`
|
||||
// Bind address for the HTTP server (default: 127.0.0.1)
|
||||
HttpBindAddr string `json:"http_bind_addr" form:"http_bind_addr" example:"127.0.0.1"`
|
||||
// Bind port for the HTTP server (default: 8000)
|
||||
HttpBindPort int `json:"http_bind_port" form:"http_bind_port" example:"8000"`
|
||||
// Runtime directory for SQLite DB and temp files (default: ./runtime)
|
||||
RuntimeRootPath string `json:"runtime_root_path" form:"runtime_root_path" example:"./runtime"`
|
||||
// Directory for application log files (default: ./logs)
|
||||
LogSavePath string `json:"log_save_path" form:"log_save_path" example:"./logs"`
|
||||
// Minutes before an unpaid order expires (default: 10)
|
||||
OrderExpirationTime int `json:"order_expiration_time" form:"order_expiration_time" example:"10"`
|
||||
// Maximum webhook retry attempts (default: 1)
|
||||
OrderNoticeMaxRetry int `json:"order_notice_max_retry" form:"order_notice_max_retry" example:"1"`
|
||||
}
|
||||
|
||||
// InstallDefaults returns sensible default values for the install form.
|
||||
func InstallDefaults() InstallRequest {
|
||||
return InstallRequest{
|
||||
AppName: "epusdt",
|
||||
AppURI: "",
|
||||
HttpBindAddr: "127.0.0.1",
|
||||
HttpBindPort: 8000,
|
||||
RuntimeRootPath: "./runtime",
|
||||
LogSavePath: "./logs",
|
||||
OrderExpirationTime: 10,
|
||||
OrderNoticeMaxRetry: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// installHandler holds the per-invocation state shared between handlers.
|
||||
type installHandler struct {
|
||||
envFilePath string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// GetDefaults returns default values for the install form.
|
||||
//
|
||||
// @Summary Install — get default values
|
||||
// @Description Returns sensible default field values for the first-run install form.
|
||||
//
|
||||
// Available only before the .env config file has been written.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Produce json
|
||||
// @Success 200 {object} InstallRequest "Default install values"
|
||||
// @Router /api/install/defaults [get]
|
||||
func (h *installHandler) GetDefaults(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, InstallDefaults())
|
||||
}
|
||||
|
||||
// Submit validates the install payload, writes the .env file, and signals
|
||||
// the install server to shut down so the main bootstrap can proceed.
|
||||
// http_bind_addr and http_bind_port are combined as "ADDR:PORT" to produce
|
||||
// the http_listen config key (e.g. 0.0.0.0:8000).
|
||||
//
|
||||
// @Summary Install — submit configuration
|
||||
// @Description Validates the submitted configuration and writes the .env file.
|
||||
//
|
||||
// http_bind_addr + http_bind_port are joined internally as "ADDR:PORT" for
|
||||
// the http_listen config key (defaults: 127.0.0.1 and 8000 respectively).
|
||||
// http_bind_port must be in the range 1–65535 if provided.
|
||||
// app_uri is required. All other fields are optional and fall back to
|
||||
// the defaults returned by GET /api/install/defaults.
|
||||
// Sets install=false in the written .env, then shuts down the install
|
||||
// server so that normal application bootstrap starts on the same port.
|
||||
// After installation completes this route is no longer served.
|
||||
//
|
||||
// @Tags Install
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body InstallRequest true "Install configuration"
|
||||
// @Success 200 {object} map[string]string "message"
|
||||
// @Failure 400 {object} map[string]string "error"
|
||||
// @Failure 500 {object} map[string]string "error"
|
||||
// @Router /api/install [post]
|
||||
func (h *installHandler) Submit(c echo.Context) error {
|
||||
req := new(InstallRequest)
|
||||
if err := c.Bind(req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
req.AppURI = strings.TrimSpace(req.AppURI)
|
||||
if req.AppURI == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "app_uri is required"})
|
||||
}
|
||||
if req.HttpBindPort != 0 && (req.HttpBindPort < 1 || req.HttpBindPort > 65535) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]interface{}{"error": "http_bind_port must be between 1 and 65535"})
|
||||
}
|
||||
d := InstallDefaults()
|
||||
if strings.TrimSpace(req.AppName) == "" {
|
||||
req.AppName = d.AppName
|
||||
}
|
||||
if strings.TrimSpace(req.HttpBindAddr) == "" {
|
||||
req.HttpBindAddr = d.HttpBindAddr
|
||||
}
|
||||
if req.HttpBindPort <= 0 {
|
||||
req.HttpBindPort = d.HttpBindPort
|
||||
}
|
||||
if strings.TrimSpace(req.RuntimeRootPath) == "" {
|
||||
req.RuntimeRootPath = d.RuntimeRootPath
|
||||
}
|
||||
if strings.TrimSpace(req.LogSavePath) == "" {
|
||||
req.LogSavePath = d.LogSavePath
|
||||
}
|
||||
if req.OrderExpirationTime <= 0 {
|
||||
req.OrderExpirationTime = d.OrderExpirationTime
|
||||
}
|
||||
if req.OrderNoticeMaxRetry < 0 {
|
||||
req.OrderNoticeMaxRetry = d.OrderNoticeMaxRetry
|
||||
}
|
||||
|
||||
if err := writeEnvFile(h.envFilePath, req); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
|
||||
go func() { close(h.done) }()
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"message": "install complete, starting server…"})
|
||||
}
|
||||
|
||||
// RunInstallServer starts the install REST API on listenAddr (default :8000)
|
||||
// under the /install path and blocks until the .env file has been written.
|
||||
// The caller should then proceed with normal app initialisation (bootstrap.InitApp).
|
||||
func RunInstallServer(listenAddr, envFilePath string) {
|
||||
if listenAddr == "" {
|
||||
listenAddr = DefaultInstallAddr
|
||||
}
|
||||
|
||||
h := &installHandler{
|
||||
envFilePath: envFilePath,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
|
||||
// api routes for the install frontend
|
||||
api := e.Group("/api")
|
||||
|
||||
api.GET("/install/defaults", h.GetDefaults)
|
||||
api.POST("/install", h.Submit)
|
||||
|
||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||
// of the working directory. main.go extracts www/ next to the binary.
|
||||
wwwRoot := "./www"
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||
}
|
||||
}
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
HTML5: true,
|
||||
Index: "index.html",
|
||||
Root: wwwRoot,
|
||||
}))
|
||||
|
||||
// Build a human-readable URL for the console hint.
|
||||
installHost := listenAddr
|
||||
if strings.HasPrefix(installHost, ":") {
|
||||
installHost = "localhost" + installHost
|
||||
}
|
||||
color.Green.Printf("[install] no config found — install API available at http://%s/install\n", installHost)
|
||||
|
||||
go func() {
|
||||
<-h.done
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = e.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
if err := e.Start(listenAddr); err != nil && err != http.ErrServerClosed {
|
||||
color.Red.Printf("[install] server error: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
color.Green.Printf("[install] configuration saved to %s, starting…\n", envFilePath)
|
||||
}
|
||||
|
||||
// formControlledKeys are keys whose values always come from the install form
|
||||
// (or are set unconditionally by the template). Existing config values for
|
||||
// these keys must NOT be preserved — the form submission takes precedence.
|
||||
var formControlledKeys = map[string]bool{
|
||||
"app_name": true,
|
||||
"app_uri": true,
|
||||
"http_listen": true,
|
||||
"runtime_root_path": true,
|
||||
"log_save_path": true,
|
||||
"order_expiration_time": true,
|
||||
"order_notice_max_retry": true,
|
||||
"install": true,
|
||||
}
|
||||
|
||||
// writeEnvFile renders and writes a minimal .env file.
|
||||
// If the file already exists, values for keys that are NOT controlled by the
|
||||
// install form are preserved from the existing file so that operator-specific
|
||||
// settings (tg_bot_token, db_type, etc.) survive a re-install.
|
||||
// Keys that the form controls (app_uri, http_listen, …) always use the
|
||||
// submitted values.
|
||||
func writeEnvFile(path string, r *InstallRequest) error {
|
||||
// Collect existing non-empty key→value pairs for non-form keys.
|
||||
existingValues := map[string]string{}
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(line, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(line[:idx])
|
||||
v := strings.TrimSpace(line[idx+1:])
|
||||
if v != "" && !formControlledKeys[k] {
|
||||
existingValues[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return fmt.Errorf("create config directory: %w", err)
|
||||
}
|
||||
|
||||
// Render the template into a buffer first.
|
||||
var buf bytes.Buffer
|
||||
if err := envTemplate.Execute(&buf, r); err != nil {
|
||||
return fmt.Errorf("render env template: %w", err)
|
||||
}
|
||||
|
||||
// For non-form keys that already had a value, substitute the existing value
|
||||
// so the template default does not clobber operator configuration.
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if idx := strings.IndexByte(trimmed, '='); idx >= 0 {
|
||||
k := strings.TrimSpace(trimmed[:idx])
|
||||
if existing, ok := existingValues[k]; ok {
|
||||
lines[i] = k + "=" + existing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open config file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprint(f, strings.Join(lines, "\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
var envTemplate = template.Must(template.New("env").Parse(`app_name={{.AppName}}
|
||||
app_uri={{.AppURI}}
|
||||
log_level=info
|
||||
http_access_log=false
|
||||
sql_debug=false
|
||||
http_listen={{.HttpBindAddr}}:{{.HttpBindPort}}
|
||||
|
||||
static_path=/static
|
||||
runtime_root_path={{.RuntimeRootPath}}
|
||||
|
||||
log_save_path={{.LogSavePath}}
|
||||
log_max_size=32
|
||||
log_max_age=7
|
||||
max_backups=3
|
||||
|
||||
# supported values: postgres,mysql,sqlite
|
||||
db_type=sqlite
|
||||
|
||||
# sqlite primary database config
|
||||
sqlite_database_filename=
|
||||
sqlite_table_prefix=
|
||||
|
||||
# sqlite runtime store config
|
||||
runtime_sqlite_filename=epusdt-runtime.db
|
||||
|
||||
# background scheduler config
|
||||
queue_concurrency=10
|
||||
queue_poll_interval_ms=1000
|
||||
callback_retry_base_seconds=5
|
||||
|
||||
order_expiration_time={{.OrderExpirationTime}}
|
||||
order_notice_max_retry={{.OrderNoticeMaxRetry}}
|
||||
|
||||
api_rate_url=
|
||||
|
||||
# Set to true to re-run the install wizard on next startup.
|
||||
install=false
|
||||
`))
|
||||
Reference in New Issue
Block a user