first commit
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASS=change_me
|
||||
DB_NAME=xboard
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASS=
|
||||
REDIS_DB=0
|
||||
|
||||
JWT_SECRET=change_me_to_a_long_random_secret
|
||||
APP_PORT=8080
|
||||
APP_URL=http://127.0.0.1:8080
|
||||
|
||||
# Plugin source reference directory, only needed during development.
|
||||
PLUGIN_ROOT=reference/LDNET-GA-Theme/plugin
|
||||
67
.gitea/workflows/build.yml
Normal file
67
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
binary_name: api
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
binary_name: api
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
binary_name: api.exe
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify build
|
||||
run: |
|
||||
go build ./cmd/api
|
||||
go build ./...
|
||||
|
||||
- name: Prepare directories
|
||||
run: |
|
||||
mkdir -p dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend
|
||||
mkdir -p dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs
|
||||
|
||||
- name: Build binary
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
run: |
|
||||
go build -trimpath -ldflags="-s -w" -o dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/${{ matrix.binary_name }} ./cmd/api
|
||||
|
||||
- name: Copy runtime files
|
||||
run: |
|
||||
cp README.md dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/README.md
|
||||
cp .env.example dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/.env.example
|
||||
cp -r frontend/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/frontend/
|
||||
cp -r docs/. dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/docs/
|
||||
cp scripts/install.sh dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/install.sh
|
||||
chmod +x dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}/install.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: dist/singbox-gopanel-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
AIAgentKey.pem
|
||||
.env
|
||||
reference/
|
||||
development/
|
||||
dist/
|
||||
177
README.md
Normal file
177
README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# SingBox-Gopanel
|
||||
|
||||
`SingBox-Gopanel` 是对 `reference/Xboard` 与 `reference/LDNET-GA-Theme` 的 Go 重构版本,当前已经完成一轮后端复刻、Nebula 用户前台接入,以及 plugin 能力向统一 Go 后端的整合。
|
||||
|
||||
## 当前状态
|
||||
|
||||
- 后端主入口为 `cmd/api/main_entry.go`
|
||||
- 用户前台已接入 `reference/LDNET-GA-Theme/theme/Nebula` 主题资源
|
||||
- 后台已改为 Go 直接提供的统一布局,并将 plugin 页面合并到左侧栏
|
||||
- Nebula 前端依赖的核心接口已补齐:
|
||||
- 登录、注册、找回密码、邮箱验证码
|
||||
- 用户信息、订阅、统计、节点、知识库、工单
|
||||
- 活跃会话查询与撤销
|
||||
- 实名认证、在线设备、IPv6 订阅 plugin API
|
||||
|
||||
## 目录说明
|
||||
|
||||
- `cmd/api`: API 启动入口
|
||||
- `internal/handler`: Gin 处理器
|
||||
- `internal/service`: 业务服务,包括插件、会话、配置等
|
||||
- `internal/model`: GORM 数据模型
|
||||
- `frontend/theme/Nebula`: 前台主题静态资源
|
||||
- `frontend/admin`: 后台统一布局的静态资源
|
||||
- `frontend/templates`: 用户前台与后台页面模板
|
||||
- `reference`: 原始参考项目与主题/plugin 实现
|
||||
- `docs/API.md`: API 文档
|
||||
|
||||
## 运行方式
|
||||
|
||||
1. 配置 `.env`
|
||||
2. 启动:
|
||||
|
||||
```powershell
|
||||
go run ./cmd/api
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```powershell
|
||||
go build ./cmd/api
|
||||
.\api.exe
|
||||
```
|
||||
|
||||
默认读取 `.env` 中的 MySQL、Valkey/Redis 与应用配置。
|
||||
|
||||
## 安装脚本
|
||||
|
||||
仓库已提供两个 Linux 脚本:
|
||||
|
||||
- `scripts/build_install.sh`:在仓库当前目录下载 Go 工具链并编译 `api`
|
||||
- `scripts/install.sh`:针对自动化构建产物目录执行安装
|
||||
|
||||
从源码构建:
|
||||
|
||||
```bash
|
||||
bash scripts/build_install.sh
|
||||
```
|
||||
|
||||
从自动化构建产物安装:
|
||||
|
||||
```bash
|
||||
cd package
|
||||
sudo bash ./install.sh --install-dir /opt/singbox-gopanel --run-user root
|
||||
```
|
||||
|
||||
脚本会执行这些步骤:
|
||||
|
||||
- 在 `/opt` 下创建安装目录及其 `frontend`、`docs` 子目录
|
||||
- 复制 `api`、`frontend`、`docs`、`README.md` 与 `.env.example`
|
||||
- 在安装目录生成 `.env`
|
||||
- 安装并重启 systemd 服务
|
||||
|
||||
默认 service 名称是 `singbox-gopanel`,可通过 `--service-name` 修改。
|
||||
|
||||
## Gitea Actions
|
||||
|
||||
仓库已新增 Gitea workflow:
|
||||
|
||||
- `.gitea/workflows/build.yml`
|
||||
|
||||
当前 workflow 会:
|
||||
|
||||
- 执行 `go build ./cmd/api`
|
||||
- 执行 `go build ./...`
|
||||
- 构建 Linux `amd64` / `arm64` 二进制
|
||||
- 按平台输出目录化制品,例如 `singbox-gopanel-linux-amd64/`
|
||||
- 将二进制、`frontend`、`docs`、`.env.example`、`install.sh` 直接作为 artifact 上传
|
||||
- 不再额外生成 `zip` / `tar.gz`
|
||||
|
||||
## 当前远端开发环境
|
||||
|
||||
以下信息基于 2026-04-17 对远端环境的实际核对:
|
||||
|
||||
- 开发服务器:`10.32.100.3`
|
||||
- SSH:使用仓库内 `AIAgentKey.pem`
|
||||
- 数据库:`xboard`
|
||||
- 数据库密码:`11223456`
|
||||
- 当前 `v2_settings.secure_path`:`helloadmin`
|
||||
- 当前 `v2_settings.app_name`:`LDNET-GA`
|
||||
- 当前 `real_name_verification`、`user_online_devices`、`user_add_ipv6_subscription` 三个 plugin 均为启用状态
|
||||
|
||||
## 前台与后台页面
|
||||
|
||||
- 用户前台:`/`
|
||||
- 用户前台备用入口:`/dashboard`
|
||||
- 后台入口:`/{secure_path}`
|
||||
- 远端当前值是 `/helloadmin`
|
||||
|
||||
后台左侧栏已整合:
|
||||
|
||||
- 总览
|
||||
- 实名认证
|
||||
- 在线设备
|
||||
- IPv6 订阅
|
||||
- Plugin 集成状态
|
||||
|
||||
## Plugin 集成结论
|
||||
|
||||
### 已完成
|
||||
|
||||
- `RealNameVerification`
|
||||
- 用户查询状态
|
||||
- 用户提交实名信息
|
||||
- 管理端记录查询、通过、驳回、重置、批量同步、批量通过
|
||||
- `auto_approve`、`allow_resubmit_after_reject`、`enforce_real_name`、`verified_expiration_date` 已接入
|
||||
|
||||
- `UserOnlineDevices`
|
||||
- 用户在线 IP 查询
|
||||
- 管理端用户在线设备监控
|
||||
- 与新的活跃会话能力联动输出 `session_overview`
|
||||
|
||||
- `UserAddIPv6Subscription`
|
||||
- 用户资格检查
|
||||
- 手动启用
|
||||
- IPv6 子账号密码同步
|
||||
- 运行时影子账号自动同步
|
||||
|
||||
### 仍需后续完整订单流配合
|
||||
|
||||
- `UserAddIPv6Subscription` 的“订单完成后立即自动创建 IPv6 子账号”这一点,仍然依赖后续订单生命周期重构完全落地后再补最终钩子。
|
||||
|
||||
## 已完成的主要改动
|
||||
|
||||
- 新增 Nebula 前台页面模板与静态资源接入
|
||||
- 新增统一后台页面与左侧栏 plugin 入口
|
||||
- 补充知识库、工单、活跃会话相关接口
|
||||
- 补充 token 快速登录、邮箱验证码、找回密码接口
|
||||
- 为 JWT 登录补充基于缓存的会话跟踪与撤销能力
|
||||
- 调整实名插件逻辑,贴近参考配置行为
|
||||
- 输出 plugin 集成状态接口,便于后台与文档统一核对
|
||||
|
||||
## 验证
|
||||
|
||||
已完成:
|
||||
|
||||
- `go build ./cmd/api`
|
||||
- `go build ./...`
|
||||
|
||||
说明:
|
||||
|
||||
- 本地编译已通过
|
||||
- 本地实际连远端 DB/Valkey 的完整页面联调仍建议在目标服务器或等价网络环境下继续验证
|
||||
## 2026-04 parity addendum
|
||||
|
||||
This pass added another backend parity sweep against `reference/Xboard`.
|
||||
|
||||
Added in this round:
|
||||
|
||||
- passport quick-login URL, mail-link compatibility, and invite PV tracking
|
||||
- user notice, invite, traffic-log, order, coupon, Telegram bot info, Stripe public key, and quick-login compatibility endpoints
|
||||
- admin config save support backed by `v2_settings`
|
||||
- missing GORM models for `v2_notice`, `v2_invite_code`, `v2_payment`, `v2_commission_log`, `v2_stat_user`, and `v2_coupon`
|
||||
|
||||
Validation after the changes:
|
||||
|
||||
- `go build ./cmd/api`
|
||||
- `go build ./...`
|
||||
92
cmd/api/main.go
Normal file
92
cmd/api/main.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/internal/config"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/handler"
|
||||
"xboard-go/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize configuration
|
||||
config.LoadConfig()
|
||||
|
||||
// Initialize database
|
||||
database.InitDB()
|
||||
|
||||
// Initialize Gin router
|
||||
r := gin.Default()
|
||||
|
||||
// Global Middleware
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
// API Groups
|
||||
v1 := r.Group("/api/v1")
|
||||
{
|
||||
// Passport (Auth)
|
||||
passport := v1.Group("/passport")
|
||||
{
|
||||
passport.POST("/login", handler.Login)
|
||||
passport.POST("/register", handler.Register)
|
||||
}
|
||||
|
||||
// Subscription (Client) Routes
|
||||
v1.GET("/s/:token", handler.Subscribe)
|
||||
|
||||
// Authenticated Routes
|
||||
auth := v1.Group("")
|
||||
auth.Use(middleware.Auth())
|
||||
{
|
||||
// User module
|
||||
user := auth.Group("/user")
|
||||
{
|
||||
user.GET("/info", handler.UserInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Admin Routes
|
||||
admin := auth.Group("/admin")
|
||||
admin.Use(middleware.AdminAuth())
|
||||
{
|
||||
admin.GET("/stats", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "Admin Stats"})
|
||||
})
|
||||
}
|
||||
|
||||
// Admin Portal (Secure Path Entry)
|
||||
v1.GET("/:path", handler.AdminPortal)
|
||||
v1.GET("/:path/realname", handler.RealNameIndex)
|
||||
|
||||
// Plugin API (Secure Path)
|
||||
realname := v1.Group("/api/v1/:path/realname")
|
||||
realname.Use(middleware.AdminAuth()) // Ensure only admins can access plugin APIs
|
||||
{
|
||||
realname.GET("/records", handler.RealNameRecords)
|
||||
realname.POST("/review/:id", handler.RealNameReview)
|
||||
}
|
||||
|
||||
// Node (UniProxy) Routes
|
||||
server := v1.Group("/server")
|
||||
server.Use(middleware.NodeAuth())
|
||||
{
|
||||
uniProxy := server.Group("/uniProxy")
|
||||
{
|
||||
uniProxy.GET("/user", handler.NodeUser)
|
||||
uniProxy.POST("/push", handler.NodePush)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
log.Printf("Server starting on port %s", config.AppConfig.AppPort)
|
||||
if err := r.Run(":" + config.AppConfig.AppPort); err != nil {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
245
cmd/api/main_entry.go
Normal file
245
cmd/api/main_entry.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"xboard-go/internal/config"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/handler"
|
||||
"xboard-go/internal/middleware"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func main() {
|
||||
config.LoadConfig()
|
||||
database.InitDB()
|
||||
database.InitCache()
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
api := router.Group("/api")
|
||||
registerV1(api.Group("/v1"))
|
||||
registerV2(api.Group("/v2"))
|
||||
registerWebRoutes(router)
|
||||
|
||||
log.Printf("server starting on port %s", config.AppConfig.AppPort)
|
||||
if err := router.Run(":" + config.AppConfig.AppPort); err != nil {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerV1(v1 *gin.RouterGroup) {
|
||||
registerPassportRoutes(v1)
|
||||
registerGuestRoutes(v1)
|
||||
registerUserRoutes(v1)
|
||||
registerClientRoutes(v1)
|
||||
registerServerRoutesV1(v1)
|
||||
registerPluginRoutesV1(v1)
|
||||
}
|
||||
|
||||
func registerV2(v2 *gin.RouterGroup) {
|
||||
registerPassportRoutes(v2)
|
||||
registerUserRoutesV2(v2)
|
||||
registerClientRoutesV2(v2)
|
||||
registerServerRoutesV2(v2)
|
||||
registerAdminRoutesV2(v2)
|
||||
}
|
||||
|
||||
func registerPassportRoutes(group *gin.RouterGroup) {
|
||||
passport := group.Group("/passport")
|
||||
passport.POST("/auth/register", handler.Register)
|
||||
passport.POST("/auth/login", handler.Login)
|
||||
passport.GET("/auth/token2Login", handler.Token2Login)
|
||||
passport.POST("/auth/forget", handler.ForgetPassword)
|
||||
passport.POST("/auth/getQuickLoginUrl", handler.PassportGetQuickLoginURL)
|
||||
passport.POST("/auth/loginWithMailLink", handler.PassportLoginWithMailLink)
|
||||
passport.POST("/comm/sendEmailVerify", handler.SendEmailVerify)
|
||||
passport.POST("/comm/pv", handler.PassportPV)
|
||||
}
|
||||
|
||||
func registerGuestRoutes(v1 *gin.RouterGroup) {
|
||||
guest := v1.Group("/guest")
|
||||
guest.GET("/plan/fetch", handler.GuestPlanFetch)
|
||||
guest.POST("/telegram/webhook", handler.NotImplemented("guest.telegram.webhook"))
|
||||
guest.Any("/payment/notify/:method/:uuid", handler.NotImplemented("guest.payment.notify"))
|
||||
guest.GET("/comm/config", handler.GuestConfig)
|
||||
}
|
||||
|
||||
func registerUserRoutes(v1 *gin.RouterGroup) {
|
||||
user := v1.Group("/user")
|
||||
user.Use(middleware.Auth())
|
||||
user.GET("/resetSecurity", handler.UserResetSecurity)
|
||||
user.GET("/info", handler.UserInfo)
|
||||
user.POST("/changePassword", handler.UserChangePassword)
|
||||
user.POST("/update", handler.UserUpdate)
|
||||
user.GET("/getSubscribe", handler.UserGetSubscribe)
|
||||
user.GET("/getStat", handler.UserGetStat)
|
||||
user.GET("/checkLogin", handler.UserCheckLogin)
|
||||
user.GET("/plan/fetch", handler.GuestPlanFetch)
|
||||
user.GET("/server/fetch", handler.UserServerFetch)
|
||||
user.GET("/comm/config", handler.UserCommConfig)
|
||||
|
||||
user.POST("/transfer", handler.UserTransfer)
|
||||
user.POST("/getQuickLoginUrl", handler.UserGetQuickLoginURL)
|
||||
user.GET("/notice/fetch", handler.UserNoticeFetch)
|
||||
user.POST("/coupon/check", handler.UserCouponCheck)
|
||||
user.POST("/gift-card/check", handler.UserGiftCardCheck)
|
||||
user.POST("/gift-card/redeem", handler.UserGiftCardRedeem)
|
||||
user.GET("/gift-card/history", handler.UserGiftCardHistory)
|
||||
user.GET("/gift-card/detail", handler.UserGiftCardDetail)
|
||||
user.GET("/gift-card/types", handler.UserGiftCardTypes)
|
||||
user.GET("/telegram/getBotInfo", handler.UserTelegramBotInfo)
|
||||
user.POST("/comm/getStripePublicKey", handler.UserGetStripePublicKey)
|
||||
user.GET("/stat/getTrafficLog", handler.UserTrafficLog)
|
||||
user.POST("/order/save", handler.UserOrderSave)
|
||||
user.POST("/order/checkout", handler.UserOrderCheckout)
|
||||
user.GET("/order/check", handler.UserOrderCheck)
|
||||
user.GET("/order/detail", handler.UserOrderDetail)
|
||||
user.GET("/order/fetch", handler.UserOrderFetch)
|
||||
user.GET("/order/getPaymentMethod", handler.UserOrderGetPaymentMethod)
|
||||
user.POST("/order/cancel", handler.UserOrderCancel)
|
||||
user.GET("/invite/save", handler.UserInviteSave)
|
||||
user.GET("/invite/fetch", handler.UserInviteFetch)
|
||||
user.GET("/invite/details", handler.UserInviteDetails)
|
||||
user.GET("/getActiveSession", handler.UserGetActiveSession)
|
||||
user.POST("/removeActiveSession", handler.UserRemoveActiveSession)
|
||||
user.GET("/knowledge/fetch", handler.UserKnowledgeFetch)
|
||||
user.GET("/knowledge/getCategory", handler.UserKnowledgeCategories)
|
||||
user.POST("/ticket/reply", handler.UserTicketReply)
|
||||
user.POST("/ticket/close", handler.UserTicketClose)
|
||||
user.POST("/ticket/save", handler.UserTicketSave)
|
||||
user.GET("/ticket/fetch", handler.UserTicketFetch)
|
||||
user.POST("/ticket/withdraw", handler.UserTicketWithdraw)
|
||||
}
|
||||
|
||||
func registerUserRoutesV2(v2 *gin.RouterGroup) {
|
||||
user := v2.Group("/user")
|
||||
user.Use(middleware.Auth())
|
||||
user.GET("/resetSecurity", handler.UserResetSecurity)
|
||||
user.GET("/info", handler.UserInfo)
|
||||
}
|
||||
|
||||
func registerClientRoutes(v1 *gin.RouterGroup) {
|
||||
client := v1.Group("/client")
|
||||
client.Use(middleware.ClientAuth())
|
||||
client.GET("/subscribe", handler.ClientSubscribe)
|
||||
client.GET("/app/getConfig", handler.ClientAppConfigV1)
|
||||
client.GET("/app/getVersion", handler.ClientAppVersion)
|
||||
}
|
||||
|
||||
func registerClientRoutesV2(v2 *gin.RouterGroup) {
|
||||
client := v2.Group("/client")
|
||||
client.Use(middleware.ClientAuth())
|
||||
client.GET("/app/getConfig", handler.ClientAppConfigV2)
|
||||
client.GET("/app/getVersion", handler.ClientAppVersion)
|
||||
}
|
||||
|
||||
func registerServerRoutesV1(v1 *gin.RouterGroup) {
|
||||
server := v1.Group("/server")
|
||||
|
||||
uniProxy := server.Group("/UniProxy")
|
||||
uniProxy.Use(middleware.NodeAuth())
|
||||
uniProxy.GET("/config", handler.NodeConfig)
|
||||
uniProxy.GET("/user", handler.NodeUser)
|
||||
uniProxy.POST("/push", handler.NodePush)
|
||||
uniProxy.POST("/alive", handler.NodeAlive)
|
||||
uniProxy.GET("/alivelist", handler.NodeAliveList)
|
||||
uniProxy.POST("/status", handler.NodeStatus)
|
||||
|
||||
shadowsocks := server.Group("/ShadowsocksTidalab")
|
||||
shadowsocks.Use(middleware.NodeAuth())
|
||||
shadowsocks.GET("/user", handler.NodeShadowsocksTidalabUser)
|
||||
shadowsocks.POST("/submit", handler.NodeTidalabSubmit)
|
||||
|
||||
trojan := server.Group("/TrojanTidalab")
|
||||
trojan.Use(middleware.NodeAuth())
|
||||
trojan.GET("/config", handler.NodeTrojanTidalabConfig)
|
||||
trojan.GET("/user", handler.NodeTrojanTidalabUser)
|
||||
trojan.POST("/submit", handler.NodeTidalabSubmit)
|
||||
}
|
||||
|
||||
func registerServerRoutesV2(v2 *gin.RouterGroup) {
|
||||
server := v2.Group("/server")
|
||||
server.Use(middleware.NodeAuth())
|
||||
server.POST("/handshake", handler.NodeHandshake)
|
||||
server.POST("/report", handler.NodeReport)
|
||||
server.GET("/config", handler.NodeConfig)
|
||||
server.GET("/user", handler.NodeUser)
|
||||
server.POST("/push", handler.NodePush)
|
||||
server.POST("/alive", handler.NodeAlive)
|
||||
server.GET("/alivelist", handler.NodeAliveList)
|
||||
server.POST("/status", handler.NodeStatus)
|
||||
}
|
||||
|
||||
func registerPluginRoutesV1(v1 *gin.RouterGroup) {
|
||||
securePath := service.GetAdminSecurePath()
|
||||
|
||||
adminPlugins := v1.Group("/" + securePath)
|
||||
adminPlugins.Use(middleware.Auth(), middleware.AdminAuth())
|
||||
adminPlugins.GET("/realname/records", handler.PluginRealNameRecords)
|
||||
adminPlugins.POST("/realname/clear-cache", handler.PluginRealNameClearCache)
|
||||
adminPlugins.POST("/realname/review/:userId", handler.PluginRealNameReview)
|
||||
adminPlugins.POST("/realname/reset/:userId", handler.PluginRealNameReset)
|
||||
adminPlugins.POST("/realname/sync-all", handler.PluginRealNameSyncAll)
|
||||
adminPlugins.POST("/realname/approve-all", handler.PluginRealNameApproveAll)
|
||||
adminPlugins.GET("/user-online-devices/users", handler.PluginUserOnlineDevicesUsers)
|
||||
|
||||
userPlugins := v1.Group("/user")
|
||||
userPlugins.Use(middleware.Auth())
|
||||
userPlugins.GET("/real-name-verification/status", handler.PluginRealNameStatus)
|
||||
userPlugins.POST("/real-name-verification/submit", handler.PluginRealNameSubmit)
|
||||
userPlugins.GET("/user-online-devices/get-ip", handler.PluginUserOnlineDevicesGetIP)
|
||||
userPlugins.POST("/user-add-ipv6-subscription/enable", handler.PluginUserAddIPv6Enable)
|
||||
userPlugins.POST("/user-add-ipv6-subscription/sync-password", handler.PluginUserAddIPv6SyncPassword)
|
||||
userPlugins.GET("/user-add-ipv6-subscription/check", handler.PluginUserAddIPv6Check)
|
||||
}
|
||||
|
||||
func registerAdminRoutesV2(v2 *gin.RouterGroup) {
|
||||
admin := v2.Group("/" + service.GetAdminSecurePath())
|
||||
admin.Use(middleware.Auth(), middleware.AdminAuth())
|
||||
|
||||
admin.GET("/config/fetch", handler.AdminConfigFetch)
|
||||
admin.POST("/config/save", handler.AdminConfigSave)
|
||||
admin.GET("/server/group/fetch", handler.AdminServerGroupsFetch)
|
||||
admin.POST("/server/group/save", handler.AdminServerGroupSave)
|
||||
admin.POST("/server/group/drop", handler.AdminServerGroupDrop)
|
||||
admin.GET("/server/route/fetch", handler.AdminServerRoutesFetch)
|
||||
admin.POST("/server/route/save", handler.AdminServerRouteSave)
|
||||
admin.POST("/server/route/drop", handler.AdminServerRouteDrop)
|
||||
admin.GET("/server/manage/getNodes", handler.AdminServerManageGetNodes)
|
||||
admin.POST("/server/manage/sort", handler.AdminServerManageSort)
|
||||
admin.POST("/server/manage/update", handler.AdminServerManageUpdate)
|
||||
admin.POST("/server/manage/save", handler.AdminServerManageSave)
|
||||
admin.POST("/server/manage/drop", handler.AdminServerManageDrop)
|
||||
admin.POST("/server/manage/copy", handler.AdminServerManageCopy)
|
||||
admin.POST("/server/manage/batchDelete", handler.AdminServerManageBatchDelete)
|
||||
admin.POST("/server/manage/resetTraffic", handler.AdminServerManageResetTraffic)
|
||||
admin.POST("/server/manage/batchResetTraffic", handler.AdminServerManageBatchResetTraffic)
|
||||
admin.GET("/system/getSystemStatus", handler.AdminSystemStatus)
|
||||
admin.GET("/plugin/getPlugins", handler.AdminPluginsList)
|
||||
admin.GET("/plugin/types", handler.AdminPluginTypes)
|
||||
admin.GET("/plugin/integration-status", handler.AdminPluginIntegrationStatus)
|
||||
}
|
||||
|
||||
func registerWebRoutes(router *gin.Engine) {
|
||||
themeRoot := filepath.Join(".", "frontend", "theme")
|
||||
adminRoot := filepath.Join(".", "frontend", "admin")
|
||||
|
||||
if _, err := os.Stat(themeRoot); err == nil {
|
||||
router.Static("/theme", themeRoot)
|
||||
}
|
||||
if _, err := os.Stat(adminRoot); err == nil {
|
||||
router.Static("/admin-assets", adminRoot)
|
||||
}
|
||||
|
||||
securePath := "/" + service.GetAdminSecurePath()
|
||||
router.GET("/", handler.UserThemePage)
|
||||
router.GET("/dashboard", handler.UserThemePage)
|
||||
router.GET(securePath, handler.AdminAppPage)
|
||||
router.GET(securePath+"/", handler.AdminAppPage)
|
||||
router.GET(securePath+"/plugins/:plugin", handler.AdminAppPage)
|
||||
}
|
||||
330
docs/API.md
Normal file
330
docs/API.md
Normal file
@@ -0,0 +1,330 @@
|
||||
# API 文档
|
||||
|
||||
本文档覆盖当前 Go 重构版本中已经接入并可用于前台/后台重构联调的主要接口。
|
||||
|
||||
## 认证与通用约定
|
||||
|
||||
- 基础前缀:`/api`
|
||||
- 用户接口:`/api/v1/user/*`
|
||||
- 后台配置接口:`/api/v2/{secure_path}/*`
|
||||
- plugin 后台接口:`/api/v1/{secure_path}/*`
|
||||
- 鉴权方式:`Authorization: Bearer <jwt>`
|
||||
- 标准成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
## Supplementary Parity Endpoints
|
||||
|
||||
The current Go backend also exposes these Xboard-compatible endpoints:
|
||||
|
||||
- `POST /api/v1/passport/auth/getQuickLoginUrl`
|
||||
- `POST /api/v1/passport/auth/loginWithMailLink`
|
||||
- `POST /api/v1/passport/comm/pv`
|
||||
- `GET /api/v1/user/comm/config`
|
||||
- `POST /api/v1/user/transfer`
|
||||
- `POST /api/v1/user/getQuickLoginUrl`
|
||||
- `GET /api/v1/user/notice/fetch`
|
||||
- `GET /api/v1/user/stat/getTrafficLog`
|
||||
- `GET /api/v1/user/invite/save`
|
||||
- `GET /api/v1/user/invite/fetch`
|
||||
- `GET /api/v1/user/invite/details`
|
||||
- `POST /api/v1/user/order/save`
|
||||
- `POST /api/v1/user/order/checkout`
|
||||
- `GET /api/v1/user/order/check`
|
||||
- `GET /api/v1/user/order/detail`
|
||||
- `GET /api/v1/user/order/fetch`
|
||||
- `GET /api/v1/user/order/getPaymentMethod`
|
||||
- `POST /api/v1/user/order/cancel`
|
||||
- `POST /api/v1/user/coupon/check`
|
||||
- `GET /api/v1/user/telegram/getBotInfo`
|
||||
- `POST /api/v1/user/comm/getStripePublicKey`
|
||||
- `POST /api/v1/user/gift-card/check`
|
||||
- `POST /api/v1/user/gift-card/redeem`
|
||||
- `GET /api/v1/user/gift-card/history`
|
||||
- `GET /api/v1/user/gift-card/detail`
|
||||
- `GET /api/v1/user/gift-card/types`
|
||||
- `POST /api/v2/{secure_path}/config/save`
|
||||
|
||||
Notes:
|
||||
|
||||
- order APIs now cover create, query, cancel, payment-method lookup, and zero-amount completion
|
||||
- invite APIs now read/write the existing `v2_*` tables directly
|
||||
- gift card routes are no longer `501`, but full reward-application business logic still needs a later dedicated migration
|
||||
|
||||
- 标准失败响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "error message"
|
||||
}
|
||||
```
|
||||
|
||||
## Passport
|
||||
|
||||
### POST `/api/v1/passport/auth/login`
|
||||
|
||||
登录。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "admin@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"token": "<jwt>",
|
||||
"auth_data": "<jwt>",
|
||||
"is_admin": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/v1/passport/auth/register`
|
||||
|
||||
注册并直接返回登录态。
|
||||
|
||||
### GET `/api/v1/passport/auth/token2Login?verify=<token>`
|
||||
|
||||
使用一次性 `verify` 令牌换取登录态。
|
||||
|
||||
### POST `/api/v1/passport/comm/sendEmailVerify`
|
||||
|
||||
生成邮箱验证码。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
响应中会返回 `debug_code`,便于当前开发阶段联调。
|
||||
|
||||
### POST `/api/v1/passport/auth/forget`
|
||||
|
||||
使用邮箱验证码重置密码。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"email_code": "123456",
|
||||
"password": "new-password"
|
||||
}
|
||||
```
|
||||
|
||||
## 用户前台接口
|
||||
|
||||
### GET `/api/v1/user/info`
|
||||
|
||||
返回当前用户信息。
|
||||
|
||||
### GET `/api/v1/user/getSubscribe`
|
||||
|
||||
返回订阅信息与订阅链接。
|
||||
|
||||
### GET `/api/v1/user/getStat`
|
||||
|
||||
返回 `[待支付订单数, 打开工单数, 邀请人数]`。
|
||||
|
||||
### GET `/api/v1/user/server/fetch`
|
||||
|
||||
返回当前用户可用节点列表。
|
||||
|
||||
### GET `/api/v1/user/checkLogin`
|
||||
|
||||
返回当前登录状态与管理员标记。
|
||||
|
||||
### GET `/api/v1/user/resetSecurity`
|
||||
|
||||
重置订阅令牌与 UUID。
|
||||
|
||||
### POST `/api/v1/user/changePassword`
|
||||
|
||||
修改密码。
|
||||
|
||||
### POST `/api/v1/user/update`
|
||||
|
||||
更新 `remind_expire` / `remind_traffic`。
|
||||
|
||||
## 知识库
|
||||
|
||||
### GET `/api/v1/user/knowledge/fetch`
|
||||
|
||||
返回知识库文章列表。
|
||||
|
||||
查询参数:
|
||||
|
||||
- `language`: 可选,默认 `zh-CN`
|
||||
|
||||
### GET `/api/v1/user/knowledge/getCategory`
|
||||
|
||||
返回知识库分类列表。
|
||||
|
||||
## 工单
|
||||
|
||||
### GET `/api/v1/user/ticket/fetch`
|
||||
|
||||
不带 `id` 时返回当前用户工单列表。
|
||||
|
||||
### GET `/api/v1/user/ticket/fetch?id=<ticketId>`
|
||||
|
||||
返回工单详情与消息列表。
|
||||
|
||||
### POST `/api/v1/user/ticket/save`
|
||||
|
||||
创建工单。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "节点异常",
|
||||
"level": 1,
|
||||
"message": "请帮我检查节点状态"
|
||||
}
|
||||
```
|
||||
|
||||
### POST `/api/v1/user/ticket/reply`
|
||||
|
||||
回复工单。
|
||||
|
||||
### POST `/api/v1/user/ticket/close`
|
||||
|
||||
关闭工单。
|
||||
|
||||
### POST `/api/v1/user/ticket/withdraw`
|
||||
|
||||
撤回工单,当前实现为将工单状态置为关闭。
|
||||
|
||||
## 活跃会话
|
||||
|
||||
### GET `/api/v1/user/getActiveSession`
|
||||
|
||||
返回当前用户活跃 JWT 会话列表。
|
||||
|
||||
### POST `/api/v1/user/removeActiveSession`
|
||||
|
||||
撤销指定会话。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Plugin: Real Name Verification
|
||||
|
||||
### GET `/api/v1/user/real-name-verification/status`
|
||||
|
||||
返回实名状态、是否可提交、实名信息掩码、审核结果等。
|
||||
|
||||
### POST `/api/v1/user/real-name-verification/submit`
|
||||
|
||||
提交实名信息。
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"real_name": "张三",
|
||||
"identity_no": "110101199001011234"
|
||||
}
|
||||
```
|
||||
|
||||
管理端:
|
||||
|
||||
- `GET /api/v1/{secure_path}/realname/records`
|
||||
- `POST /api/v1/{secure_path}/realname/review/{userId}`
|
||||
- `POST /api/v1/{secure_path}/realname/reset/{userId}`
|
||||
- `POST /api/v1/{secure_path}/realname/sync-all`
|
||||
- `POST /api/v1/{secure_path}/realname/approve-all`
|
||||
- `POST /api/v1/{secure_path}/realname/clear-cache`
|
||||
|
||||
## Plugin: User Online Devices
|
||||
|
||||
### GET `/api/v1/user/user-online-devices/get-ip`
|
||||
|
||||
返回:
|
||||
|
||||
- 在线 IP 列表
|
||||
- `session_overview`
|
||||
- 在线设备数
|
||||
- 设备限制
|
||||
- 活跃会话列表
|
||||
|
||||
管理端:
|
||||
|
||||
- `GET /api/v1/{secure_path}/user-online-devices/users`
|
||||
|
||||
## Plugin: User Add IPv6 Subscription
|
||||
|
||||
### GET `/api/v1/user/user-add-ipv6-subscription/check`
|
||||
|
||||
检查当前用户是否允许启用 IPv6 子账号。
|
||||
|
||||
### POST `/api/v1/user/user-add-ipv6-subscription/enable`
|
||||
|
||||
启用或同步 IPv6 影子账号。
|
||||
|
||||
### POST `/api/v1/user/user-add-ipv6-subscription/sync-password`
|
||||
|
||||
将主账号密码同步到 IPv6 子账号。
|
||||
|
||||
## 后台统一接口
|
||||
|
||||
### GET `/api/v2/{secure_path}/config/fetch`
|
||||
|
||||
返回应用名称、站点 URL、后台路径、节点拉取/推送周期。
|
||||
|
||||
### GET `/api/v2/{secure_path}/system/getSystemStatus`
|
||||
|
||||
返回系统时间与站点基础信息。
|
||||
|
||||
### GET `/api/v2/{secure_path}/plugin/getPlugins`
|
||||
|
||||
返回 `v2_plugins` 列表。
|
||||
|
||||
### GET `/api/v2/{secure_path}/plugin/types`
|
||||
|
||||
返回插件类型。
|
||||
|
||||
### GET `/api/v2/{secure_path}/plugin/integration-status`
|
||||
|
||||
返回当前 plugin 在 Go 后端中的整合完成度说明。
|
||||
|
||||
示例字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"real_name_verification": {
|
||||
"enabled": true,
|
||||
"status": "complete"
|
||||
},
|
||||
"user_online_devices": {
|
||||
"enabled": true,
|
||||
"status": "complete"
|
||||
},
|
||||
"user_add_ipv6_subscription": {
|
||||
"enabled": true,
|
||||
"status": "integrated_with_runtime_sync"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
677
frontend/admin/app.css
Normal file
677
frontend/admin/app.css
Normal file
@@ -0,0 +1,677 @@
|
||||
:root {
|
||||
--bg: #eff3f9;
|
||||
--bg-accent: #f8fbff;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f5f8fc;
|
||||
--surface-strong: #ecf2fb;
|
||||
--border: #d9e1ef;
|
||||
--border-strong: #c8d4e7;
|
||||
--text: #172033;
|
||||
--muted: #61708b;
|
||||
--muted-soft: #8c9ab2;
|
||||
--brand: #1677ff;
|
||||
--brand-strong: #0f5fd6;
|
||||
--brand-soft: #eaf2ff;
|
||||
--success: #168a54;
|
||||
--warn: #d97706;
|
||||
--danger: #c0392b;
|
||||
--sidebar: #0f1728;
|
||||
--sidebar-panel: #141f35;
|
||||
--sidebar-muted: #8ea0c3;
|
||||
--sidebar-active: #ffffff;
|
||||
--sidebar-border: rgba(255, 255, 255, 0.08);
|
||||
--shadow: 0 20px 48px rgba(15, 23, 42, 0.08);
|
||||
--shadow-soft: 0 12px 28px rgba(15, 23, 42, 0.05);
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(22, 119, 255, 0.12), transparent 28%),
|
||||
radial-gradient(circle at right top, rgba(15, 95, 214, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, var(--bg-accent) 0%, var(--bg) 100%);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 272px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
padding: 26px 18px 22px;
|
||||
color: #fff;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
|
||||
linear-gradient(180deg, #121c2f 0%, #0d1524 100%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 18px 16px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--sidebar-border);
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02));
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-brand-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(22, 119, 255, 0.16);
|
||||
color: #b9d5ff;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-brand strong {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
line-height: 1.15;
|
||||
color: var(--sidebar-active);
|
||||
}
|
||||
|
||||
.sidebar-brand span:last-child {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sidebar-nav-label,
|
||||
.sidebar-meta-label {
|
||||
display: block;
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-nav-label {
|
||||
margin: 22px 12px 10px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
position: relative;
|
||||
display: block;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 16px;
|
||||
padding: 14px 14px 14px 16px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
transform: translateX(2px);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
background: linear-gradient(180deg, rgba(22, 119, 255, 0.22), rgba(22, 119, 255, 0.12));
|
||||
border-color: rgba(95, 162, 255, 0.28);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-item.active::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
top: 14px;
|
||||
bottom: 14px;
|
||||
width: 3px;
|
||||
border-radius: 999px;
|
||||
background: #7db3ff;
|
||||
}
|
||||
|
||||
.sidebar-item strong {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--sidebar-active);
|
||||
}
|
||||
|
||||
.sidebar-item span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.sidebar-meta,
|
||||
.sidebar-footer {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--sidebar-border);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.sidebar-meta strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--sidebar-active);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
min-width: 0;
|
||||
padding: 22px 26px 30px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 18px 22px;
|
||||
margin-bottom: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.topbar-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar-eyebrow {
|
||||
display: inline-flex;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--brand-soft);
|
||||
color: var(--brand-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.topbar-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.topbar-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 11px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.page-hero,
|
||||
.page-section,
|
||||
.plugin-card,
|
||||
.stat-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(240px, 0.8fr);
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.page-hero-copy h2 {
|
||||
margin: 10px 0 10px;
|
||||
font-size: 28px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-hero-copy p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.page-hero-label,
|
||||
.section-kicker {
|
||||
display: inline-flex;
|
||||
color: var(--brand-strong);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.page-hero-side {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-metric {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 6px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, var(--surface-soft), #fff);
|
||||
}
|
||||
|
||||
.hero-metric span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hero-metric strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid rgba(255, 255, 255, 0.72);
|
||||
border-radius: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.card h2,
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.section-headline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-headline h2 {
|
||||
margin-top: 8px;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
max-width: 520px;
|
||||
color: var(--muted);
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 30px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 14px;
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, var(--brand) 0%, var(--brand-strong) 100%);
|
||||
box-shadow: 0 10px 20px rgba(22, 119, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: var(--brand-strong);
|
||||
background: var(--brand-soft);
|
||||
border-color: rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: fit-content;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-ok {
|
||||
background: rgba(22, 138, 84, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
background: rgba(192, 57, 43, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 680px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 14px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--surface-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: min(480px, 100%);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 1px solid rgba(255, 255, 255, 0.76);
|
||||
border-radius: 28px;
|
||||
padding: 30px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.login-card form,
|
||||
.filter-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select,
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus,
|
||||
.field textarea:focus {
|
||||
border-color: rgba(22, 119, 255, 0.4);
|
||||
box-shadow: 0 0 0 4px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
.notice {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notice.error {
|
||||
background: rgba(192, 57, 43, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.notice.success {
|
||||
background: rgba(22, 138, 84, 0.12);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
border-radius: 16px;
|
||||
background: #0f172a;
|
||||
color: #dbe7ff;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.code-panel {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table-code {
|
||||
max-width: 360px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
background: #111b31;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.page-hero,
|
||||
.plugin-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: static;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.card {
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-meta,
|
||||
.sidebar-footer {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.page-hero-copy h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
border-radius: 14px;
|
||||
}
|
||||
}
|
||||
694
frontend/admin/app.js
Normal file
694
frontend/admin/app.js
Normal file
@@ -0,0 +1,694 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var cfg = window.ADMIN_APP_CONFIG || {};
|
||||
var api = cfg.api || {};
|
||||
var root = document.getElementById("admin-app");
|
||||
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var state = {
|
||||
token: readToken(),
|
||||
user: null,
|
||||
route: normalizeRoute(readRoute()),
|
||||
message: "",
|
||||
messageType: "",
|
||||
config: null,
|
||||
system: null,
|
||||
plugins: [],
|
||||
integration: {},
|
||||
realname: null,
|
||||
devices: null,
|
||||
busy: false
|
||||
};
|
||||
|
||||
boot();
|
||||
|
||||
async function boot() {
|
||||
window.addEventListener("hashchange", function () {
|
||||
state.route = normalizeRoute(readRoute());
|
||||
render();
|
||||
hydrateRoute();
|
||||
});
|
||||
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("submit", onSubmit);
|
||||
|
||||
if (state.token) {
|
||||
await loadBootstrap();
|
||||
}
|
||||
|
||||
render();
|
||||
hydrateRoute();
|
||||
}
|
||||
|
||||
async function loadBootstrap() {
|
||||
try {
|
||||
state.busy = true;
|
||||
var loginCheck = unwrap(await request("/api/v1/user/checkLogin", { method: "GET" }));
|
||||
if (!loginCheck || !loginCheck.is_login || !loginCheck.is_admin) {
|
||||
clearSession();
|
||||
return;
|
||||
}
|
||||
|
||||
state.user = loginCheck;
|
||||
|
||||
var results = await Promise.all([
|
||||
request(api.adminConfig, { method: "GET" }),
|
||||
request(api.systemStatus, { method: "GET" }),
|
||||
request(api.plugins, { method: "GET" }),
|
||||
request(api.integration, { method: "GET" })
|
||||
]);
|
||||
|
||||
state.config = unwrap(results[0]) || {};
|
||||
state.system = unwrap(results[1]) || {};
|
||||
state.plugins = toArray(unwrap(results[2]));
|
||||
state.integration = unwrap(results[3]) || {};
|
||||
} catch (error) {
|
||||
clearSession();
|
||||
show(error.message || "Failed to load admin data.", "error");
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateRoute() {
|
||||
if (!state.user) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (state.route === "realname") {
|
||||
state.realname = unwrap(await request(api.realnameBase + "/records?per_page=50", { method: "GET" })) || {};
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.route === "user-online-devices") {
|
||||
state.devices = unwrap(await request(api.onlineDevices + "?per_page=50", { method: "GET" })) || {};
|
||||
render();
|
||||
}
|
||||
} catch (error) {
|
||||
show(error.message || "Failed to load page data.", "error");
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var actionEl = event.target.closest("[data-action]");
|
||||
if (!actionEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var action = actionEl.getAttribute("data-action");
|
||||
|
||||
if (action === "logout") {
|
||||
clearSession();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "nav") {
|
||||
window.location.hash = actionEl.getAttribute("data-route") || "overview";
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "refresh") {
|
||||
refreshAll();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "approve-all") {
|
||||
adminPost(api.realnameBase + "/approve-all", {}, "Approved all pending records.").then(hydrateRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "sync-all") {
|
||||
adminPost(api.realnameBase + "/sync-all", {}, "Triggered real-name sync.").then(hydrateRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "clear-cache") {
|
||||
adminPost(api.realnameBase + "/clear-cache", {}, "Cleared plugin cache.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "review") {
|
||||
var userId = actionEl.getAttribute("data-user-id");
|
||||
var status = actionEl.getAttribute("data-status") || "approved";
|
||||
var reason = "";
|
||||
if (status === "rejected") {
|
||||
reason = window.prompt("Reject reason", "") || "";
|
||||
}
|
||||
|
||||
adminPost(
|
||||
api.realnameBase + "/review/" + encodeURIComponent(userId),
|
||||
{ status: status, reason: reason },
|
||||
"Updated review result."
|
||||
).then(hydrateRoute);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "reset-record") {
|
||||
var resetUserId = actionEl.getAttribute("data-user-id");
|
||||
adminPost(
|
||||
api.realnameBase + "/reset/" + encodeURIComponent(resetUserId),
|
||||
{},
|
||||
"Reset the verification record."
|
||||
).then(hydrateRoute);
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(event) {
|
||||
var form = event.target;
|
||||
if (form.getAttribute("data-form") !== "login") {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
var formData = serializeForm(form);
|
||||
|
||||
request("/api/v1/passport/auth/login", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
body: formData
|
||||
}).then(function (response) {
|
||||
var payload = unwrap(response);
|
||||
if (!payload || !payload.auth_data || !payload.is_admin) {
|
||||
throw new Error("This account does not have admin access.");
|
||||
}
|
||||
|
||||
saveToken(payload.auth_data);
|
||||
state.token = readToken();
|
||||
return loadBootstrap();
|
||||
}).then(function () {
|
||||
show("Admin login successful.", "success");
|
||||
render();
|
||||
hydrateRoute();
|
||||
}).catch(function (error) {
|
||||
show(error.message || "Login failed.", "error");
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
state.realname = null;
|
||||
state.devices = null;
|
||||
state.busy = true;
|
||||
render();
|
||||
loadBootstrap().then(function () {
|
||||
render();
|
||||
return hydrateRoute();
|
||||
});
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
clearToken();
|
||||
state.user = null;
|
||||
state.config = null;
|
||||
state.system = null;
|
||||
state.plugins = [];
|
||||
state.integration = {};
|
||||
state.realname = null;
|
||||
state.devices = null;
|
||||
state.route = "overview";
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
function render() {
|
||||
root.innerHTML = state.user ? renderDashboard() : renderLogin();
|
||||
}
|
||||
|
||||
function renderLogin() {
|
||||
return [
|
||||
'<div class="login-shell">',
|
||||
'<section class="login-card stack">',
|
||||
'<div><h1 style="margin:0 0 8px;">' + escapeHtml(cfg.title || "Admin") + '</h1><p class="hint">Use an administrator account to sign in and open the rebuilt backend workspace.</p></div>',
|
||||
state.message ? renderNotice() : "",
|
||||
'<form data-form="login">',
|
||||
'<div class="field"><label>Email</label><input type="email" name="email" autocomplete="username" required /></div>',
|
||||
'<div class="field"><label>Password</label><input type="password" name="password" autocomplete="current-password" required /></div>',
|
||||
'<button class="btn btn-primary" type="submit">Sign In</button>',
|
||||
'</form>',
|
||||
'<div class="hint">The backend now uses a single Go-rendered admin shell with integrated plugin pages.</div>',
|
||||
'</section>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
return [
|
||||
'<div class="admin-shell">',
|
||||
renderSidebar(),
|
||||
'<main class="admin-main">',
|
||||
renderTopbar(),
|
||||
'<div class="page-shell">',
|
||||
state.message ? renderNotice() : "",
|
||||
renderMainContent(),
|
||||
'</div>',
|
||||
'</main>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
var items = [
|
||||
navItem("overview", "Overview", "System status, plugin visibility, and entry summary"),
|
||||
navItem("realname", "Real Name", "Review and operate the verification workflow"),
|
||||
navItem("user-online-devices", "Online Devices", "Inspect user sessions and online device data"),
|
||||
navItem("ipv6-subscription", "IPv6 Subscription", "Check the IPv6 shadow-account integration state"),
|
||||
navItem("plugin-status", "Plugin Status", "Compare current backend integrations by module")
|
||||
];
|
||||
|
||||
return [
|
||||
'<aside class="admin-sidebar">',
|
||||
'<div class="sidebar-brand"><span class="sidebar-brand-mark">Console</span><strong>' + escapeHtml(cfg.title || "Admin") + '</strong><span>Secure Path: /' + escapeHtml(getSecurePath()) + '</span></div>',
|
||||
'<div class="sidebar-nav-label">Workspace</div>',
|
||||
'<nav class="sidebar-nav">' + items.join("") + '</nav>',
|
||||
'<div class="sidebar-meta"><span class="sidebar-meta-label">Current View</span><strong>' + escapeHtml(getRouteTitle(state.route)) + '</strong></div>',
|
||||
'<div class="sidebar-footer">The layout keeps the classic backend pattern: dark sidebar, top workspace bar, and a card-based content area.</div>',
|
||||
'</aside>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderTopbar() {
|
||||
return [
|
||||
'<header class="topbar">',
|
||||
'<div class="topbar-copy">',
|
||||
'<span class="topbar-eyebrow">Admin Workspace</span>',
|
||||
'<h1>' + escapeHtml(getRouteTitle(state.route)) + '</h1>',
|
||||
'<p>' + escapeHtml(getRouteDescription(state.route)) + '</p>',
|
||||
'<div class="topbar-meta">',
|
||||
'<span class="topbar-chip">Secure Path /' + escapeHtml(getSecurePath()) + '</span>',
|
||||
'<span class="topbar-chip">Server Time ' + escapeHtml(formatDate(state.system && state.system.server_time)) + '</span>',
|
||||
state.busy ? '<span class="topbar-chip">Refreshing</span>' : "",
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="toolbar"><button class="btn btn-secondary" data-action="refresh">Refresh</button><button class="btn btn-ghost" data-action="logout">Logout</button></div>',
|
||||
'</header>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderMainContent() {
|
||||
if (state.route === "realname") {
|
||||
return renderRealName();
|
||||
}
|
||||
if (state.route === "user-online-devices") {
|
||||
return renderOnlineDevices();
|
||||
}
|
||||
if (state.route === "ipv6-subscription") {
|
||||
return renderIPv6Integration();
|
||||
}
|
||||
if (state.route === "plugin-status") {
|
||||
return renderPluginStatus();
|
||||
}
|
||||
return renderOverview();
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
var enabledCount = countEnabledPlugins();
|
||||
return [
|
||||
'<section class="page-hero card">',
|
||||
'<div class="page-hero-copy">',
|
||||
'<span class="page-hero-label">Overview</span>',
|
||||
'<h2>Classic backend structure, rebuilt with the current Go APIs.</h2>',
|
||||
'<p>The page keeps the familiar admin navigation pattern while exposing plugin data, system state, and backend integration results in one place.</p>',
|
||||
'</div>',
|
||||
'<div class="page-hero-side">',
|
||||
'<div class="hero-metric"><span>Enabled Plugins</span><strong>' + escapeHtml(String(enabledCount)) + '</strong></div>',
|
||||
'<div class="hero-metric"><span>Secure Path</span><strong>/' + escapeHtml(getSecurePath()) + '</strong></div>',
|
||||
'</div>',
|
||||
'</section>',
|
||||
'<section class="grid grid-3">',
|
||||
statCard("Server Time", formatDate(state.system && state.system.server_time), "Source: /system/getSystemStatus"),
|
||||
statCard("Admin Path", "/" + getSecurePath(), "Synced from current backend settings"),
|
||||
statCard("Plugin Count", String((state.plugins || []).length), "Read from the integrated plugin list"),
|
||||
'</section>',
|
||||
'<section class="card page-section"><div class="section-headline"><div><span class="section-kicker">Plugins</span><h2>Integrated plugin list</h2></div><p class="section-copy">Each entry reflects the current Go backend response and the copied integration status.</p></div><div class="table-wrap">' + renderPluginsTable() + '</div></section>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderRealName() {
|
||||
var rows = toArray(state.realname, "data");
|
||||
var loading = state.realname === null;
|
||||
return [
|
||||
'<section class="card stack page-section">',
|
||||
'<div class="section-headline"><div><span class="section-kicker">Workflow</span><h2>Real-name verification</h2></div><p class="section-copy">Batch actions stay in the toolbar while the review table keeps the classic admin operating rhythm.</p></div>',
|
||||
'<div class="toolbar"><button class="btn btn-primary" data-action="approve-all">Approve All</button><button class="btn btn-secondary" data-action="sync-all">Sync All</button><button class="btn btn-ghost" data-action="clear-cache">Clear Cache</button></div>',
|
||||
'<div class="table-wrap"><table><thead><tr><th>ID</th><th>Email</th><th>Status</th><th>Real Name</th><th>ID Number</th><th>Submitted At</th><th>Actions</th></tr></thead><tbody>',
|
||||
loading ? '<tr><td colspan="7">Loading verification records...</td></tr>' : rows.length ? rows.map(function (row) {
|
||||
return [
|
||||
'<tr>',
|
||||
'<td>' + escapeHtml(String(row.id || "")) + '</td>',
|
||||
'<td>' + escapeHtml(row.email || "-") + '</td>',
|
||||
'<td>' + renderStatus(row.status) + '</td>',
|
||||
'<td>' + escapeHtml(row.real_name || "-") + '</td>',
|
||||
'<td>' + escapeHtml(row.identity_no_masked || "-") + '</td>',
|
||||
'<td>' + escapeHtml(formatDate(row.submitted_at)) + '</td>',
|
||||
'<td><div class="row-actions"><button class="btn btn-secondary" data-action="review" data-user-id="' + escapeHtml(String(row.id || "")) + '" data-status="approved">Approve</button><button class="btn btn-ghost" data-action="review" data-user-id="' + escapeHtml(String(row.id || "")) + '" data-status="rejected">Reject</button><button class="btn btn-ghost" data-action="reset-record" data-user-id="' + escapeHtml(String(row.id || "")) + '">Reset</button></div></td>',
|
||||
'</tr>'
|
||||
].join("");
|
||||
}).join("") : '<tr><td colspan="7">No verification records available.</td></tr>',
|
||||
'</tbody></table></div>',
|
||||
'</section>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderOnlineDevices() {
|
||||
var rows = toArray(state.devices, "list");
|
||||
var loading = state.devices === null;
|
||||
return [
|
||||
'<section class="card stack page-section">',
|
||||
'<div class="section-headline"><div><span class="section-kicker">Monitoring</span><h2>Online devices</h2></div><p class="section-copy">This table keeps the admin-friendly scan pattern for sessions, subscription names, IPs, and recent activity.</p></div>',
|
||||
'<div class="table-wrap"><table><thead><tr><th>User</th><th>Subscription</th><th>Online Count</th><th>Online IPs</th><th>Last Seen</th><th>Created At</th></tr></thead><tbody>',
|
||||
loading ? '<tr><td colspan="6">Loading device records...</td></tr>' : rows.length ? rows.map(function (row) {
|
||||
return [
|
||||
'<tr>',
|
||||
'<td>' + escapeHtml(row.email || "-") + '</td>',
|
||||
'<td>' + escapeHtml(row.subscription_name || "-") + '</td>',
|
||||
'<td>' + escapeHtml(String(row.online_count || 0)) + '</td>',
|
||||
'<td>' + escapeHtml(formatDeviceList(row.online_devices)) + '</td>',
|
||||
'<td>' + escapeHtml(row.last_online_text || "-") + '</td>',
|
||||
'<td>' + escapeHtml(row.created_text || "-") + '</td>',
|
||||
'</tr>'
|
||||
].join("");
|
||||
}).join("") : '<tr><td colspan="6">No online device records available.</td></tr>',
|
||||
'</tbody></table></div>',
|
||||
'</section>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderIPv6Integration() {
|
||||
var integration = (state.integration && state.integration.user_add_ipv6_subscription) || {};
|
||||
return [
|
||||
'<section class="card stack page-section">',
|
||||
'<div class="section-headline"><div><span class="section-kicker">Integration</span><h2>IPv6 shadow subscription</h2></div><p class="section-copy">' + escapeHtml(buildSummary(integration, "This panel shows the current runtime status of the IPv6 shadow-account integration.")) + '</p></div>',
|
||||
'<div class="status-strip">' + renderStatus(integration.status || "unknown") + '</div>',
|
||||
'<pre class="code-panel">' + escapeHtml(stringifyJSON(integration)) + '</pre>',
|
||||
'</section>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderPluginStatus() {
|
||||
var cards = [
|
||||
sectionCard("Real-name verification", state.integration && state.integration.real_name_verification),
|
||||
sectionCard("Online devices", state.integration && state.integration.user_online_devices),
|
||||
sectionCard("IPv6 shadow subscription", state.integration && state.integration.user_add_ipv6_subscription)
|
||||
];
|
||||
|
||||
return '<section class="grid plugin-grid">' + cards.join("") + '</section>';
|
||||
}
|
||||
|
||||
function renderPluginsTable() {
|
||||
var rows = toArray(state.plugins);
|
||||
return '<table><thead><tr><th>ID</th><th>Code</th><th>Status</th><th>Config</th></tr></thead><tbody>' + (rows.length ? rows.map(function (row) {
|
||||
return '<tr><td>' + escapeHtml(String(row.id || "")) + '</td><td>' + escapeHtml(row.code || row.name || "-") + '</td><td>' + renderStatus(row.is_enabled ? "enabled" : "disabled") + '</td><td><pre class="table-code">' + escapeHtml(formatPluginConfig(row.config)) + '</pre></td></tr>';
|
||||
}).join("") : '<tr><td colspan="4">No plugin data available.</td></tr>') + '</tbody></table>';
|
||||
}
|
||||
|
||||
function sectionCard(title, data) {
|
||||
data = data || {};
|
||||
return [
|
||||
'<article class="card stack plugin-card">',
|
||||
'<div class="section-headline"><div><span class="section-kicker">Module</span><h2>' + escapeHtml(title) + '</h2></div></div>',
|
||||
'<p class="section-copy">' + escapeHtml(buildSummary(data, "No summary has been returned by the backend for this module.")) + '</p>',
|
||||
renderStatus(data.status || "unknown"),
|
||||
'<pre class="code-panel">' + escapeHtml(stringifyJSON(data)) + '</pre>',
|
||||
'</article>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function navItem(route, title, desc) {
|
||||
return '<a class="sidebar-item ' + (state.route === route ? "active" : "") + '" data-action="nav" data-route="' + escapeHtml(route) + '" href="#' + escapeHtml(route) + '"><strong>' + escapeHtml(title) + '</strong><span>' + escapeHtml(desc) + '</span></a>';
|
||||
}
|
||||
|
||||
function statCard(title, value, hint) {
|
||||
return '<article class="card stat stat-card"><span class="hint">' + escapeHtml(title) + '</span><strong>' + escapeHtml(value || "-") + '</strong><p>' + escapeHtml(hint || "") + '</p></article>';
|
||||
}
|
||||
|
||||
function renderNotice() {
|
||||
return '<div class="notice ' + escapeHtml(state.messageType || "") + '">' + escapeHtml(state.message || "") + '</div>';
|
||||
}
|
||||
|
||||
function countEnabledPlugins() {
|
||||
return toArray(state.plugins).filter(function (item) {
|
||||
return !!item.is_enabled;
|
||||
}).length;
|
||||
}
|
||||
|
||||
function buildSummary(data, fallback) {
|
||||
if (!data) {
|
||||
return fallback;
|
||||
}
|
||||
if (typeof data.summary === "string" && data.summary.trim()) {
|
||||
return data.summary.trim();
|
||||
}
|
||||
if (typeof data.message === "string" && data.message.trim()) {
|
||||
return data.message.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function formatPluginConfig(value) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
return stringifyJSON(value || {});
|
||||
}
|
||||
|
||||
function stringifyJSON(value) {
|
||||
try {
|
||||
return JSON.stringify(value == null ? {} : value, null, 2);
|
||||
} catch (error) {
|
||||
return String(value == null ? "" : value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDeviceList(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(", ") || "-";
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value;
|
||||
}
|
||||
return "-";
|
||||
}
|
||||
|
||||
function toArray(value, preferredKey) {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return [];
|
||||
}
|
||||
if (preferredKey && Array.isArray(value[preferredKey])) {
|
||||
return value[preferredKey];
|
||||
}
|
||||
if (Array.isArray(value.data)) {
|
||||
return value.data;
|
||||
}
|
||||
if (Array.isArray(value.list)) {
|
||||
return value.list;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function request(url, options) {
|
||||
options = options || {};
|
||||
|
||||
var headers = {
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
if (options.auth !== false && state.token) {
|
||||
headers.Authorization = state.token;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
method: options.method || "GET",
|
||||
headers: headers,
|
||||
credentials: "same-origin",
|
||||
body: options.body ? JSON.stringify(options.body) : undefined
|
||||
}).then(async function (response) {
|
||||
var payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (error) {
|
||||
payload = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearSession();
|
||||
render();
|
||||
}
|
||||
throw new Error(getErrorMessage(payload) || "Request failed.");
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
function adminPost(url, body, successMessage) {
|
||||
return request(url, {
|
||||
method: "POST",
|
||||
body: body || {}
|
||||
}).then(function (payload) {
|
||||
show(successMessage || "Operation completed.", "success");
|
||||
render();
|
||||
return payload;
|
||||
}).catch(function (error) {
|
||||
show(error.message || "Operation failed.", "error");
|
||||
render();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function unwrap(payload) {
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return typeof payload.data !== "undefined" ? payload.data : payload;
|
||||
}
|
||||
|
||||
function getErrorMessage(payload) {
|
||||
if (!payload) {
|
||||
return "";
|
||||
}
|
||||
return payload.message || payload.msg || payload.error || "";
|
||||
}
|
||||
|
||||
function saveToken(token) {
|
||||
var normalized = /^Bearer /.test(token) ? token : "Bearer " + token;
|
||||
window.localStorage.setItem("__gopanel_admin_auth__", normalized);
|
||||
}
|
||||
|
||||
function serializeForm(form) {
|
||||
var result = {};
|
||||
var formData = new FormData(form);
|
||||
formData.forEach(function (value, key) {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function readToken() {
|
||||
var token = window.localStorage.getItem("__gopanel_admin_auth__") ||
|
||||
window.localStorage.getItem("__nebula_auth_data__") ||
|
||||
window.localStorage.getItem("auth_data") ||
|
||||
"";
|
||||
|
||||
if (!token) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return /^Bearer /.test(token) ? token : "Bearer " + token;
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
window.localStorage.removeItem("__gopanel_admin_auth__");
|
||||
state.token = "";
|
||||
}
|
||||
|
||||
function readRoute() {
|
||||
return (window.location.hash || "#overview").slice(1) || "overview";
|
||||
}
|
||||
|
||||
function normalizeRoute(route) {
|
||||
var allowed = {
|
||||
overview: true,
|
||||
realname: true,
|
||||
"user-online-devices": true,
|
||||
"ipv6-subscription": true,
|
||||
"plugin-status": true
|
||||
};
|
||||
|
||||
return allowed[route] ? route : "overview";
|
||||
}
|
||||
|
||||
function getSecurePath() {
|
||||
return (state.config && state.config.secure_path) || cfg.securePath || "admin";
|
||||
}
|
||||
|
||||
function getRouteTitle(route) {
|
||||
if (route === "realname") {
|
||||
return "Real-name Verification";
|
||||
}
|
||||
if (route === "user-online-devices") {
|
||||
return "Online Devices";
|
||||
}
|
||||
if (route === "ipv6-subscription") {
|
||||
return "IPv6 Subscription";
|
||||
}
|
||||
if (route === "plugin-status") {
|
||||
return "Plugin Status";
|
||||
}
|
||||
return "Overview";
|
||||
}
|
||||
|
||||
function getRouteDescription(route) {
|
||||
if (route === "realname") {
|
||||
return "Review records, run batch actions, and keep the original backend workflow readable.";
|
||||
}
|
||||
if (route === "user-online-devices") {
|
||||
return "Inspect active users, current devices, and recent online activity in one table.";
|
||||
}
|
||||
if (route === "ipv6-subscription") {
|
||||
return "Check the replicated backend integration output for the IPv6 shadow-account module.";
|
||||
}
|
||||
if (route === "plugin-status") {
|
||||
return "Compare plugin integration payloads and runtime summaries side by side.";
|
||||
}
|
||||
return "A familiar backend workspace with sidebar navigation, top control bar, and focused content cards.";
|
||||
}
|
||||
|
||||
function show(message, type) {
|
||||
state.message = message || "";
|
||||
state.messageType = type || "";
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
var numeric = Number(value);
|
||||
if (!Number.isNaN(numeric) && numeric > 0) {
|
||||
return new Date(numeric * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
var parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
var raw = String(status || "unknown");
|
||||
var normalized = raw.toLowerCase();
|
||||
var klass = "status-pill status-ok";
|
||||
|
||||
if (normalized.indexOf("warn") !== -1 || normalized.indexOf("pending") !== -1 || normalized.indexOf("runtime") !== -1) {
|
||||
klass = "status-pill status-warn";
|
||||
}
|
||||
|
||||
if (normalized.indexOf("disabled") !== -1 || normalized.indexOf("fail") !== -1 || normalized.indexOf("error") !== -1 || normalized.indexOf("reject") !== -1 || normalized.indexOf("unknown") !== -1) {
|
||||
klass = "status-pill status-danger";
|
||||
}
|
||||
|
||||
return '<span class="' + klass + '">' + escapeHtml(raw) + '</span>';
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? "" : value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
16
frontend/templates/admin_app.html
Normal file
16
frontend/templates/admin_app.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{.Title}}</title>
|
||||
<link rel="stylesheet" href="/admin-assets/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="admin-app"></div>
|
||||
<script>
|
||||
window.ADMIN_APP_CONFIG = {{.ConfigJSON}};
|
||||
</script>
|
||||
<script src="/admin-assets/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
54
frontend/templates/user_nebula.html
Normal file
54
frontend/templates/user_nebula.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,minimum-scale=1,user-scalable=no" />
|
||||
<meta name="theme-color" content="#08101c" />
|
||||
<title>{{.Title}}</title>
|
||||
<script>
|
||||
window.routerBase = "/";
|
||||
window.settings = {
|
||||
title: {{printf "%q" .Title}},
|
||||
assets_path: {{printf "%q" .AssetsPath}},
|
||||
theme: { color: "aurora" },
|
||||
version: {{printf "%q" .Version}},
|
||||
background_url: "",
|
||||
description: {{printf "%q" .Description}},
|
||||
i18n: ["zh-CN", "en-US", "ja-JP", "vi-VN", "ko-KR", "zh-TW", "fa-IR"],
|
||||
logo: {{printf "%q" .Logo}}
|
||||
};
|
||||
|
||||
window.NEBULA_THEME = {
|
||||
title: {{printf "%q" .Title}},
|
||||
theme: {{printf "%q" .Theme}},
|
||||
version: {{printf "%q" .Version}},
|
||||
description: {{printf "%q" .Description}},
|
||||
logo: {{printf "%q" .Logo}},
|
||||
assetsPath: {{printf "%q" .AssetsPath}},
|
||||
config: {{.ThemeConfigJSON}}
|
||||
};
|
||||
|
||||
document.documentElement.dataset.accent = window.NEBULA_THEME.config.accent || "aurora";
|
||||
</script>
|
||||
<link rel="stylesheet" href="{{.AssetsPath}}/app.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="nebula-shell" aria-hidden="true">
|
||||
<div class="nebula-glow nebula-glow-a"></div>
|
||||
<div class="nebula-glow nebula-glow-b"></div>
|
||||
<div class="nebula-ring nebula-ring-a"></div>
|
||||
<div class="nebula-ring nebula-ring-b"></div>
|
||||
</div>
|
||||
<div id="nebula-loader" class="nebula-loading">
|
||||
<div class="nebula-loading__card">
|
||||
<span class="nebula-pill">Nebula Theme</span>
|
||||
<h1>{{.Title}}</h1>
|
||||
<p>Go backend rebuild with original Nebula user experience.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="nebula-toasts"></div>
|
||||
<div id="app"></div>
|
||||
<script src="{{.AssetsPath}}/app.js" defer></script>
|
||||
{{.CustomHTML}}
|
||||
</body>
|
||||
</html>
|
||||
1923
frontend/theme/Nebula/assets/app.css
Normal file
1923
frontend/theme/Nebula/assets/app.css
Normal file
File diff suppressed because it is too large
Load Diff
2911
frontend/theme/Nebula/assets/app.js
Normal file
2911
frontend/theme/Nebula/assets/app.js
Normal file
File diff suppressed because it is too large
Load Diff
429
frontend/theme/Nebula/assets/enhancer.js
Normal file
429
frontend/theme/Nebula/assets/enhancer.js
Normal file
@@ -0,0 +1,429 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var theme = window.NEBULA_THEME || {};
|
||||
var loader = document.getElementById("nebula-loader");
|
||||
var appRoot = document.getElementById("app");
|
||||
|
||||
applyCustomBackground();
|
||||
mountShell();
|
||||
mountMonitor();
|
||||
hideLoaderWhenReady();
|
||||
|
||||
function mountShell() {
|
||||
if (!appRoot || document.getElementById("nebula-app-shell")) {
|
||||
return;
|
||||
}
|
||||
|
||||
var shell = document.createElement("div");
|
||||
shell.className = "app-shell nebula-app-shell";
|
||||
shell.id = "nebula-app-shell";
|
||||
shell.innerHTML = renderShellChrome();
|
||||
|
||||
var parent = appRoot.parentNode;
|
||||
parent.insertBefore(shell, appRoot);
|
||||
|
||||
var appStage = shell.querySelector(".nebula-app-stage");
|
||||
if (appStage) {
|
||||
appStage.appendChild(appRoot);
|
||||
}
|
||||
}
|
||||
|
||||
function mountMonitor() {
|
||||
var rail = document.getElementById("nebula-side-rail");
|
||||
var panel = document.createElement("aside");
|
||||
panel.className = "nebula-monitor";
|
||||
panel.id = "nebula-monitor";
|
||||
panel.innerHTML = renderLoadingPanel();
|
||||
|
||||
if (rail) {
|
||||
rail.appendChild(panel);
|
||||
} else {
|
||||
document.body.appendChild(panel);
|
||||
}
|
||||
|
||||
panel.addEventListener("click", function (event) {
|
||||
var actionEl = event.target.closest("[data-nebula-action]");
|
||||
if (!actionEl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var action = actionEl.getAttribute("data-nebula-action");
|
||||
if (action === "refresh") {
|
||||
loadDeviceOverview(panel);
|
||||
}
|
||||
if (action === "toggle") {
|
||||
panel.classList.toggle("is-collapsed");
|
||||
}
|
||||
});
|
||||
|
||||
loadDeviceOverview(panel);
|
||||
window.setInterval(function () {
|
||||
loadDeviceOverview(panel, true);
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async function loadDeviceOverview(panel, silent) {
|
||||
if (!silent) {
|
||||
panel.innerHTML = renderLoadingPanel();
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await fetch("/api/v1/user/user-online-devices/get-ip", {
|
||||
method: "GET",
|
||||
headers: getRequestHeaders(),
|
||||
credentials: "same-origin"
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
panel.innerHTML = renderGuestPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Unable to load device overview");
|
||||
}
|
||||
|
||||
var resJson = await response.json();
|
||||
var data = resJson && resJson.data ? resJson.data : {};
|
||||
console.log("[Nebula] Device overview data:", data); // Diagnostic log
|
||||
var overview = data.session_overview || data;
|
||||
|
||||
panel.innerHTML = renderPanel(overview);
|
||||
} catch (error) {
|
||||
panel.innerHTML = renderErrorPanel(error.message || "Unable to load device overview");
|
||||
}
|
||||
}
|
||||
|
||||
function renderShellChrome() {
|
||||
return [
|
||||
'<header class="topbar nebula-topbar glass-card">',
|
||||
'<div class="brand">',
|
||||
'<div class="brand-mark"></div>',
|
||||
'<div>',
|
||||
'<h1>' + escapeHtml(theme.title || "Nebula") + '</h1>',
|
||||
'<p>' + escapeHtml(theme.description || "Refined user workspace") + '</p>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div class="topbar-actions">',
|
||||
'<span class="tiny-pill">Nebula Theme</span>',
|
||||
'<span class="tiny-pill">Platform Control Surface</span>',
|
||||
'</div>',
|
||||
'</header>',
|
||||
'<section class="dashboard-hero nebula-hero-layout">',
|
||||
'<article class="hero glass-card">',
|
||||
'<span class="nebula-pill">User Console</span>',
|
||||
'<h2>Subscriptions, sessions, and live access in one view.</h2>',
|
||||
'<p>' + escapeHtml((theme.config && theme.config.slogan) || "Current IP visibility, online devices, and the original workflow remain available.") + '</p>',
|
||||
'<div class="metric-strip">',
|
||||
'<div class="metric-box"><span class="value">Live</span><span class="label">Current ingress overview</span></div>',
|
||||
'<div class="metric-box"><span class="value">Native</span><span class="label">Original features retained</span></div>',
|
||||
'<div class="metric-box"><span class="value">Refresh</span><span class="label">Auto-updated every 30 seconds</span></div>',
|
||||
'</div>',
|
||||
'</article>',
|
||||
'<aside class="section-card glass-card nebula-side-summary">',
|
||||
'<div class="section-head"><div><span class="tiny-pill">Workspace</span><h3>Account operations hub</h3></div></div>',
|
||||
'<div class="stack">',
|
||||
'<div class="notice-item"><div class="notice-copy"><strong>Current IP visibility</strong><div class="notice-meta">Track how many IPs and devices are online for the signed-in user.</div></div></div>',
|
||||
'<div class="notice-item"><div class="notice-copy"><strong>Original dashboard compatibility</strong><div class="notice-meta">The core application bundle is still powering forms, plans, notices, and navigation.</div></div></div>',
|
||||
'<div class="notice-item"><div class="notice-copy"><strong>Immersive layout</strong><div class="notice-meta">A new command-center shell wraps the app with clearer spacing, hierarchy, and status context.</div></div></div>',
|
||||
'</div>',
|
||||
'</aside>',
|
||||
'</section>',
|
||||
'<section class="nebula-main-grid">',
|
||||
'<div class="nebula-app-stage glass-card"></div>',
|
||||
'<aside class="nebula-side-rail" id="nebula-side-rail"></aside>',
|
||||
'</section>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderLoadingPanel() {
|
||||
return [
|
||||
'<div class="nebula-monitor__head">',
|
||||
'<div><h2 class="nebula-monitor__title">Live Access Monitor</h2><div class="nebula-monitor__copy">Checking online IPs, devices, and active sessions...</div></div>',
|
||||
'<div class="nebula-monitor__actions"><button data-nebula-action="toggle">Collapse</button></div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__body">',
|
||||
'<div class="nebula-monitor__section"><div class="nebula-monitor__empty">Loading...</div></div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderGuestPanel() {
|
||||
return [
|
||||
'<div class="nebula-monitor__head">',
|
||||
'<div><h2 class="nebula-monitor__title">Live Access Monitor</h2><div class="nebula-monitor__copy">The panel is ready, but this browser session is not authenticated for the API yet.</div></div>',
|
||||
'<div class="nebula-monitor__actions"><button data-nebula-action="refresh">Retry</button><button data-nebula-action="toggle">Collapse</button></div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__body">',
|
||||
'<div class="nebula-monitor__section"><div class="nebula-monitor__empty">Sign in first, then refresh this panel to view current IP and device data.</div></div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderErrorPanel(message) {
|
||||
return [
|
||||
'<div class="nebula-monitor__head">',
|
||||
'<div><h2 class="nebula-monitor__title">Live Access Monitor</h2><div class="nebula-monitor__copy">The panel loaded, but the device overview endpoint did not respond as expected.</div></div>',
|
||||
'<div class="nebula-monitor__actions"><button data-nebula-action="refresh">Retry</button><button data-nebula-action="toggle">Collapse</button></div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__body">',
|
||||
'<div class="nebula-monitor__section"><div class="nebula-monitor__empty">' + escapeHtml(message) + '</div></div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function renderPanel(data) {
|
||||
var ips = Array.isArray(data.online_ips) ? data.online_ips : [];
|
||||
var sessions = Array.isArray(data.sessions) ? data.sessions.slice(0, 4) : [];
|
||||
|
||||
return [
|
||||
'<div class="nebula-monitor__head">',
|
||||
'<div><h2 class="nebula-monitor__title">Live Access Monitor</h2><div class="nebula-monitor__copy">' + escapeHtml((theme.config && theme.config.slogan) || "Current IP and session visibility") + '</div></div>',
|
||||
'<div class="nebula-monitor__actions"><button data-nebula-action="refresh">Refresh</button><button data-nebula-action="toggle">Collapse</button></div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__body">',
|
||||
'<div class="nebula-monitor__section">',
|
||||
'<div class="nebula-monitor__grid">',
|
||||
metric(String(data.online_ip_count || 0), "Current IPs"),
|
||||
metric(String(data.online_device_count || 0), "Online devices"),
|
||||
metric(String(data.active_session_count || 0), "Stored sessions"),
|
||||
metric(formatLimit(data.device_limit), "Device limit"),
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__meta">Last online: ' + formatDate(data.last_online_at) + '</div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__section">',
|
||||
'<div class="nebula-monitor__meta">Online IP addresses</div>',
|
||||
ips.length ? '<div class="nebula-monitor__chips">' + ips.map(function (ip) {
|
||||
return '<div class="nebula-monitor__chip"><code>' + escapeHtml(ip) + '</code></div>';
|
||||
}).join("") + '</div>' : '<div class="nebula-monitor__empty">No IP data reported yet.</div>',
|
||||
'</div>',
|
||||
'<div class="nebula-monitor__section">',
|
||||
'<div class="nebula-monitor__meta">Recent sessions</div>',
|
||||
sessions.length ? '<div class="nebula-monitor__sessions">' + sessions.map(function (session) {
|
||||
return [
|
||||
'<div class="nebula-monitor__session">',
|
||||
'<strong>' + escapeHtml(session.name || ("Session #" + session.id)) + (session.is_current ? " · current" : "") + '</strong>',
|
||||
'<div class="nebula-monitor__meta">Last used: ' + formatDate(session.last_used_at) + '</div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}).join("") + '</div>' : '<div class="nebula-monitor__empty">No session records available.</div>',
|
||||
'</div>',
|
||||
'</div>'
|
||||
].join("");
|
||||
}
|
||||
|
||||
function metric(value, label) {
|
||||
return '<div class="nebula-monitor__metric"><strong>' + escapeHtml(value) + '</strong><span>' + escapeHtml(label) + '</span></div>';
|
||||
}
|
||||
|
||||
function getRequestHeaders() {
|
||||
var headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest"
|
||||
};
|
||||
var token = getStoredToken();
|
||||
if (token) {
|
||||
headers.Authorization = token;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function hideLoaderWhenReady() {
|
||||
if (!loader) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hide = function () {
|
||||
loader.classList.add("is-hidden");
|
||||
window.setTimeout(function () {
|
||||
if (loader && loader.parentNode) {
|
||||
loader.parentNode.removeChild(loader);
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
|
||||
if (appRoot && appRoot.children.length > 0) {
|
||||
hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(function () {
|
||||
if (appRoot && appRoot.children.length > 0) {
|
||||
observer.disconnect();
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
if (appRoot) {
|
||||
observer.observe(appRoot, { childList: true });
|
||||
}
|
||||
|
||||
window.setTimeout(hide, 6000);
|
||||
}
|
||||
|
||||
function applyCustomBackground() {
|
||||
if (!theme.config || !theme.config.backgroundUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.body.style.backgroundImage =
|
||||
'linear-gradient(180deg, rgba(5, 13, 22, 0.76), rgba(5, 13, 22, 0.9)), url("' + String(theme.config.backgroundUrl).replace(/"/g, '\\"') + '")';
|
||||
document.body.style.backgroundSize = "cover";
|
||||
document.body.style.backgroundPosition = "center";
|
||||
document.body.style.backgroundAttachment = "fixed";
|
||||
}
|
||||
|
||||
function getStoredToken() {
|
||||
var candidates = [];
|
||||
var directKeys = ["access_token", "auth_data", "__nebula_auth_data__", "token", "auth_token"];
|
||||
collectStorageValues(window.localStorage, directKeys, candidates);
|
||||
collectStorageValues(window.sessionStorage, directKeys, candidates);
|
||||
collectCookieValues(candidates);
|
||||
collectGlobalValues(candidates);
|
||||
|
||||
for (var i = 0; i < candidates.length; i += 1) {
|
||||
var token = extractToken(candidates[i]);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function collectStorageValues(storage, keys, target) {
|
||||
if (!storage) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < keys.length; i += 1) {
|
||||
pushCandidate(storage.getItem(keys[i]), target);
|
||||
}
|
||||
}
|
||||
|
||||
function collectCookieValues(target) {
|
||||
if (!document.cookie) {
|
||||
return;
|
||||
}
|
||||
var cookies = document.cookie.split(";");
|
||||
for (var i = 0; i < cookies.length; i += 1) {
|
||||
var part = cookies[i].split("=");
|
||||
if (part.length < 2) {
|
||||
continue;
|
||||
}
|
||||
var key = String(part[0] || "").trim();
|
||||
if (key.indexOf("token") !== -1 || key.indexOf("auth") !== -1) {
|
||||
pushCandidate(decodeURIComponent(part.slice(1).join("=")), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectGlobalValues(target) {
|
||||
pushCandidate(window.__INITIAL_STATE__, target);
|
||||
pushCandidate(window.g_initialProps, target);
|
||||
pushCandidate(window.g_app, target);
|
||||
}
|
||||
|
||||
function pushCandidate(value, target) {
|
||||
if (value === null || typeof value === "undefined" || value === "") {
|
||||
return;
|
||||
}
|
||||
target.push(value);
|
||||
}
|
||||
|
||||
function extractToken(value, depth) {
|
||||
if (depth > 4 || value === null || typeof value === "undefined") {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
var trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.indexOf("Bearer ") === 0) {
|
||||
return trimmed;
|
||||
}
|
||||
if (/^[A-Za-z0-9\-_=]+\.[A-Za-z0-9\-_=]+(\.[A-Za-z0-9\-_.+/=]+)?$/.test(trimmed)) {
|
||||
return "Bearer " + trimmed;
|
||||
}
|
||||
if (/^[A-Za-z0-9\-_.+/=]{24,}$/.test(trimmed) && trimmed.indexOf("{") === -1) {
|
||||
return "Bearer " + trimmed;
|
||||
}
|
||||
if ((trimmed.charAt(0) === "{" && trimmed.charAt(trimmed.length - 1) === "}") ||
|
||||
(trimmed.charAt(0) === "[" && trimmed.charAt(trimmed.length - 1) === "]")) {
|
||||
try {
|
||||
return extractToken(JSON.parse(trimmed), (depth || 0) + 1);
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (var i = 0; i < value.length; i += 1) {
|
||||
var nestedToken = extractToken(value[i], (depth || 0) + 1);
|
||||
if (nestedToken) {
|
||||
return nestedToken;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
var keys = ["access_token", "auth_data", "token", "Authorization", "authorization"];
|
||||
for (var j = 0; j < keys.length; j += 1) {
|
||||
if (Object.prototype.hasOwnProperty.call(value, keys[j])) {
|
||||
var directToken = extractToken(value[keys[j]], (depth || 0) + 1);
|
||||
if (directToken) {
|
||||
return directToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var key in value) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
||||
continue;
|
||||
}
|
||||
var token = extractToken(value[key], (depth || 0) + 1);
|
||||
if (token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
return new Date(value * 1000).toLocaleString();
|
||||
}
|
||||
var parsed = Date.parse(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
|
||||
function formatLimit(value) {
|
||||
if (value === null || typeof value === "undefined" || value === "") {
|
||||
return "Unlimited";
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
22
frontend/theme/Nebula/assets/images/nebula-grid.svg
Normal file
22
frontend/theme/Nebula/assets/images/nebula-grid.svg
Normal file
@@ -0,0 +1,22 @@
|
||||
<svg width="1600" height="1200" viewBox="0 0 1600 1200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1600" height="1200" fill="#06101D"/>
|
||||
<g opacity="0.36">
|
||||
<path d="M0 120H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 240H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 360H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 480H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 600H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 720H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 840H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M0 960H1600" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M160 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M320 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M480 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M640 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M800 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M960 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M1120 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M1280 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
<path d="M1440 0V1200" stroke="#83B8FF" stroke-opacity="0.16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
2
frontend/theme/Nebula/assets/umi.js
Normal file
2
frontend/theme/Nebula/assets/umi.js
Normal file
File diff suppressed because one or more lines are too long
99
frontend/theme/Nebula/config.json
Normal file
99
frontend/theme/Nebula/config.json
Normal file
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "Nebula",
|
||||
"description": "Nebula rebuilt user theme",
|
||||
"version": "2.0.0",
|
||||
"images": "",
|
||||
"configs": [
|
||||
{
|
||||
"label": "主题配色",
|
||||
"placeholder": "选择主题主色方案",
|
||||
"field_name": "theme_color",
|
||||
"field_type": "select",
|
||||
"select_options": {
|
||||
"aurora": "极光蓝",
|
||||
"sunset": "日落橙",
|
||||
"ember": "余烬红",
|
||||
"violet": "星云紫"
|
||||
},
|
||||
"default_value": "aurora"
|
||||
},
|
||||
{
|
||||
"label": "左侧自定义文字",
|
||||
"placeholder": "显示在登录页左侧主视觉下方的大标题文案",
|
||||
"field_name": "hero_slogan",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "登录欢迎目标文字",
|
||||
"placeholder": "将显示为 WELCOME TO 下方的名称,例如 CloudYun",
|
||||
"field_name": "welcome_target",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "注册标题",
|
||||
"placeholder": "注册面板顶部标题文案",
|
||||
"field_name": "register_title",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "登录页背景图地址",
|
||||
"placeholder": "可选,填写外部背景图片链接",
|
||||
"field_name": "background_url",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "指标接口域名",
|
||||
"placeholder": "例如:https://your-domain.com",
|
||||
"field_name": "metrics_base_url",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "默认主题模式",
|
||||
"placeholder": "选择默认显示的主题模式",
|
||||
"field_name": "default_theme_mode",
|
||||
"field_type": "select",
|
||||
"select_options": {
|
||||
"system": "跟随系统",
|
||||
"dark": "黑夜模式",
|
||||
"light": "白日模式"
|
||||
},
|
||||
"default_value": "system"
|
||||
},
|
||||
{
|
||||
"label": "白日模式 Logo",
|
||||
"placeholder": "白日模式下显示的 Logo 地址",
|
||||
"field_name": "light_logo_url",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "黑夜模式 Logo",
|
||||
"placeholder": "黑夜模式下显示的 Logo 地址",
|
||||
"field_name": "dark_logo_url",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "ICP备案号",
|
||||
"placeholder": "例如:粤 ICP 备 12345678 号",
|
||||
"field_name": "icp_no",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "公安备案号",
|
||||
"placeholder": "例如:粤公网安备 12345678901234 号",
|
||||
"field_name": "psb_no",
|
||||
"field_type": "input"
|
||||
},
|
||||
{
|
||||
"label": "自定义 HTML",
|
||||
"placeholder": "可选,用于统计代码或自定义挂件",
|
||||
"field_name": "custom_html",
|
||||
"field_type": "textarea"
|
||||
},
|
||||
{
|
||||
"label": "静态资源 CDN 地址",
|
||||
"placeholder": "例如:https://cdn.example.com/nebula (末尾不要带 /)",
|
||||
"field_name": "static_cdn_url",
|
||||
"field_type": "input"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
go.mod
Normal file
49
go.mod
Normal file
@@ -0,0 +1,49 @@
|
||||
module xboard-go
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/joho/godotenv v1.5.1 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gorm.io/driver/mysql v1.6.0 // indirect
|
||||
gorm.io/gorm v1.31.1 // indirect
|
||||
)
|
||||
112
go.sum
Normal file
112
go.sum
Normal file
@@ -0,0 +1,112 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
71
internal/config/config.go
Normal file
71
internal/config/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPass string
|
||||
DBName string
|
||||
RedisHost string
|
||||
RedisPort string
|
||||
RedisPass string
|
||||
RedisDB int
|
||||
JWTSecret string
|
||||
AppPort string
|
||||
AppURL string
|
||||
PluginRoot string
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func LoadConfig() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
AppConfig = &Config{
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPass: getEnv("DB_PASS", ""),
|
||||
DBName: getEnv("DB_NAME", "xboard"),
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPass: getEnv("REDIS_PASS", ""),
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
AppURL: getEnv("APP_URL", ""),
|
||||
PluginRoot: getEnv("PLUGIN_ROOT", "reference\\LDNET-GA-Theme\\plugin"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
raw := getEnv(key, "")
|
||||
if raw == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
139
internal/database/cache.go
Normal file
139
internal/database/cache.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
"xboard-go/internal/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var Redis *redis.Client
|
||||
|
||||
type memoryEntry struct {
|
||||
Value []byte
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type memoryCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]memoryEntry
|
||||
}
|
||||
|
||||
var fallbackCache = &memoryCache{
|
||||
items: make(map[string]memoryEntry),
|
||||
}
|
||||
|
||||
func InitCache() {
|
||||
addr := net.JoinHostPort(config.AppConfig.RedisHost, config.AppConfig.RedisPort)
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: config.AppConfig.RedisPass,
|
||||
DB: config.AppConfig.RedisDB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("Redis/Valkey unavailable, falling back to in-memory cache: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Redis = client
|
||||
log.Printf("Redis/Valkey connection established at %s", addr)
|
||||
}
|
||||
|
||||
func CacheSet(key string, value any, ttl time.Duration) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if Redis != nil {
|
||||
if err := Redis.Set(context.Background(), key, payload, ttl).Err(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCache.Set(key, payload, ttl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheDelete(key string) error {
|
||||
if Redis != nil {
|
||||
if err := Redis.Del(context.Background(), key).Err(); err == nil {
|
||||
fallbackCache.Delete(key)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCache.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheGetJSON[T any](key string) (T, bool) {
|
||||
var zero T
|
||||
var payload []byte
|
||||
|
||||
if Redis != nil {
|
||||
value, err := Redis.Get(context.Background(), key).Bytes()
|
||||
if err == nil {
|
||||
payload = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
value, ok := fallbackCache.Get(key)
|
||||
if !ok {
|
||||
return zero, false
|
||||
}
|
||||
payload = value
|
||||
}
|
||||
|
||||
var result T
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (m *memoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entry := memoryEntry{
|
||||
Value: value,
|
||||
}
|
||||
if ttl > 0 {
|
||||
entry.ExpiresAt = time.Now().Add(ttl)
|
||||
}
|
||||
m.items[key] = entry
|
||||
}
|
||||
|
||||
func (m *memoryCache) Get(key string) ([]byte, bool) {
|
||||
m.mu.RLock()
|
||||
entry, ok := m.items[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if !entry.ExpiresAt.IsZero() && time.Now().After(entry.ExpiresAt) {
|
||||
m.Delete(key)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.Value, true
|
||||
}
|
||||
|
||||
func (m *memoryCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.items, key)
|
||||
}
|
||||
34
internal/database/db.go
Normal file
34
internal/database/db.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"xboard-go/internal/config"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.AppConfig.DBUser,
|
||||
config.AppConfig.DBPass,
|
||||
config.AppConfig.DBHost,
|
||||
config.AppConfig.DBPort,
|
||||
config.AppConfig.DBName,
|
||||
)
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
}
|
||||
230
internal/handler/admin_handler.go
Normal file
230
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminPortal(c *gin.Context) {
|
||||
// Load settings for the portal
|
||||
var appNameSetting model.Setting
|
||||
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
|
||||
appName := appNameSetting.Value
|
||||
if appName == "" {
|
||||
appName = "XBoard Admin"
|
||||
}
|
||||
|
||||
securePath := c.Param("path")
|
||||
if securePath == "" {
|
||||
securePath = "admin" // fallback
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - 管理控制台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0f172a;
|
||||
--bg-accent: #1e293b;
|
||||
--primary: #6366f1;
|
||||
--secondary: #a855f7;
|
||||
--text-main: #f8fafc;
|
||||
--text-dim: #94a3b8;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated Background Gradients */
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 150vw;
|
||||
height: 150vh;
|
||||
background: radial-gradient(circle at 30%% 30%%, rgba(99, 102, 241, 0.15), transparent 40%%),
|
||||
radial-gradient(circle at 70%% 70%%, rgba(168, 85, 247, 0.15), transparent 40%%);
|
||||
animation: drift 20s infinite alternate ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
from { transform: translate(-10%%, -10%%) rotate(0deg); }
|
||||
to { transform: translate(10%%, 10%%) rotate(5deg); }
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%%;
|
||||
max-width: 1000px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
p.subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 3rem 2rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%%;
|
||||
height: 100%%;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), rgba(168, 85, 247, 0.05));
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.portal-card:hover {
|
||||
transform: translateY(-10px) scale(1.02);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.portal-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--bg-accent);
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: var(--primary);
|
||||
transition: transform 0.4s;
|
||||
}
|
||||
|
||||
.portal-card:hover .card-icon {
|
||||
transform: rotate(-5deg) scale(1.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 100px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
h1 { font-size: 2.2rem; }
|
||||
.portal-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>%s</h1>
|
||||
<p class="subtitle">欢迎来到管理中心,请选择进入的功能模块</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<!-- Admin Dashboard -->
|
||||
<a href="/admin/" class="portal-card">
|
||||
<div class="card-icon">⚙️</div>
|
||||
<div class="card-title">系统管理后台</div>
|
||||
<div class="card-desc">管理用户、套餐、节点及系统全局配置</div>
|
||||
</a>
|
||||
|
||||
<!-- Real-Name Verification -->
|
||||
<a href="/%s/realname" class="portal-card">
|
||||
<div class="badge">插件</div>
|
||||
<div class="card-icon">🆔</div>
|
||||
<div class="card-title">实名验证中心</div>
|
||||
<div class="card-desc">审核用户实名信息,确保站点运营安全</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, appName, appName, securePath)
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
1015
internal/handler/admin_server_api.go
Normal file
1015
internal/handler/admin_server_api.go
Normal file
File diff suppressed because it is too large
Load Diff
1009
internal/handler/admin_server_api.go.8129289363901765875
Normal file
1009
internal/handler/admin_server_api.go.8129289363901765875
Normal file
File diff suppressed because it is too large
Load Diff
230
internal/handler/auth_api.go
Normal file
230
internal/handler/auth_api.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
InviteCode *string `json:"invite_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
Fail(c, http.StatusUnauthorized, "email or password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.Password, user.Password, user.PasswordAlgo, user.PasswordSalt) {
|
||||
Fail(c, http.StatusUnauthorized, "email or password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
_ = database.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
Fail(c, http.StatusBadRequest, "email already exists")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
tokenRaw := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+req.Email)))
|
||||
user := model.User{
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
UUID: uuid.New().String(),
|
||||
Token: tokenRaw[:16],
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "register failed")
|
||||
return
|
||||
}
|
||||
|
||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && user.PlanID != nil {
|
||||
service.SyncIPv6ShadowAccount(&user)
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Token2Login(c *gin.Context) {
|
||||
verify := strings.TrimSpace(c.Query("verify"))
|
||||
if verify == "" {
|
||||
Fail(c, http.StatusBadRequest, "verify token is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := service.ResolveQuickLoginToken(verify)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "verify token is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
_ = database.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", time.Now().Unix()).Error
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func SendEmailVerify(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
|
||||
code, err := randomDigits(6)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to generate verify code")
|
||||
return
|
||||
}
|
||||
|
||||
service.StoreEmailVerifyCode(strings.ToLower(strings.TrimSpace(req.Email)), code, 10*time.Minute)
|
||||
SuccessMessage(c, "email verify code generated", gin.H{
|
||||
"email": req.Email,
|
||||
"debug_code": code,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func ForgetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
EmailCode string `json:"email_code" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if !service.CheckEmailVerifyCode(email, strings.TrimSpace(req.EmailCode)) {
|
||||
Fail(c, http.StatusBadRequest, "email verify code is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"password": hashed,
|
||||
"password_algo": nil,
|
||||
"password_salt": nil,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}
|
||||
if err := database.DB.Model(&model.User{}).Where("email = ?", email).Updates(updates).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "password reset failed")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "password reset success", true)
|
||||
}
|
||||
|
||||
func randomDigits(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoded := hex.EncodeToString(buf)
|
||||
digits := make([]byte, 0, length)
|
||||
for i := 0; i < len(encoded) && len(digits) < length; i++ {
|
||||
digits = append(digits, '0'+(encoded[i]%10))
|
||||
}
|
||||
return string(digits), nil
|
||||
}
|
||||
102
internal/handler/auth_handler.go
Normal file
102
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
InviteCode *string `json:"invite_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "生成Token失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "该邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "系统错误"})
|
||||
return
|
||||
}
|
||||
|
||||
newUUID := uuid.New().String()
|
||||
// Generate a 16-character random token for compatibility
|
||||
tokenRaw := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+req.Email)))
|
||||
token := tokenRaw[:16]
|
||||
|
||||
user := model.User{
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
UUID: newUUID,
|
||||
Token: token,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "注册成功",
|
||||
})
|
||||
}
|
||||
144
internal/handler/client_api.go
Normal file
144
internal/handler/client_api.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/protocol"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ClientSubscribe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusForbidden, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
|
||||
ua := c.GetHeader("User-Agent")
|
||||
flag := c.Query("flag")
|
||||
if strings.Contains(ua, "Clash") || flag == "clash" {
|
||||
config, _ := protocol.GenerateClash(servers, *user)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(ua), "sing-box") || flag == "sing-box" {
|
||||
config, _ := protocol.GenerateSingBox(servers, *user)
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
links := make([]string, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
links = append(links, "vmess://"+server.Name)
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Subscription-Userinfo", subscriptionUserInfo(*user))
|
||||
c.String(http.StatusOK, base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n"))))
|
||||
}
|
||||
|
||||
func ClientAppConfigV1(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusForbidden, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
|
||||
config, _ := protocol.GenerateClash(servers, *user)
|
||||
c.Header("Content-Type", "text/yaml; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func ClientAppConfigV2(c *gin.Context) {
|
||||
config := gin.H{
|
||||
"app_info": gin.H{
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_description": service.MustGetString("app_description", ""),
|
||||
"app_url": service.GetAppURL(),
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"version": service.MustGetString("app_version", "1.0.0"),
|
||||
},
|
||||
"features": gin.H{
|
||||
"enable_register": service.MustGetBool("app_enable_register", true),
|
||||
"enable_invite_system": service.MustGetBool("app_enable_invite_system", true),
|
||||
"enable_telegram_bot": service.MustGetBool("telegram_bot_enable", false),
|
||||
"enable_ticket_system": service.MustGetBool("app_enable_ticket_system", true),
|
||||
"enable_commission_system": service.MustGetBool("app_enable_commission_system", true),
|
||||
"enable_traffic_log": service.MustGetBool("app_enable_traffic_log", true),
|
||||
"enable_knowledge_base": service.MustGetBool("app_enable_knowledge_base", true),
|
||||
"enable_announcements": service.MustGetBool("app_enable_announcements", true),
|
||||
},
|
||||
"security_config": gin.H{
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
"privacy_policy_url": service.MustGetString("app_privacy_policy_url", ""),
|
||||
"is_email_verify": service.MustGetInt("email_verify", 0),
|
||||
"is_invite_force": service.MustGetInt("invite_force", 0),
|
||||
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
||||
"is_captcha": service.MustGetInt("captcha_enable", 0),
|
||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
|
||||
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
|
||||
},
|
||||
}
|
||||
|
||||
Success(c, config)
|
||||
}
|
||||
|
||||
func ClientAppVersion(c *gin.Context) {
|
||||
ua := strings.ToLower(c.GetHeader("User-Agent"))
|
||||
if strings.Contains(ua, "tidalab/4.0.0") || strings.Contains(ua, "tunnelab/4.0.0") {
|
||||
if strings.Contains(ua, "win64") {
|
||||
Success(c, gin.H{
|
||||
"version": service.MustGetString("windows_version", ""),
|
||||
"download_url": service.MustGetString("windows_download_url", ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"version": service.MustGetString("macos_version", ""),
|
||||
"download_url": service.MustGetString("macos_download_url", ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"windows_version": service.MustGetString("windows_version", ""),
|
||||
"windows_download_url": service.MustGetString("windows_download_url", ""),
|
||||
"macos_version": service.MustGetString("macos_version", ""),
|
||||
"macos_download_url": service.MustGetString("macos_download_url", ""),
|
||||
"android_version": service.MustGetString("android_version", ""),
|
||||
"android_download_url": service.MustGetString("android_download_url", ""),
|
||||
})
|
||||
}
|
||||
|
||||
func subscriptionUserInfo(user model.User) string {
|
||||
expire := int64(0)
|
||||
if user.ExpiredAt != nil {
|
||||
expire = *user.ExpiredAt
|
||||
}
|
||||
return "upload=" + strconv.FormatInt(int64(user.U), 10) +
|
||||
"; download=" + strconv.FormatInt(int64(user.D), 10) +
|
||||
"; total=" + strconv.FormatInt(int64(user.TransferEnable), 10) +
|
||||
"; expire=" + strconv.FormatInt(expire, 10)
|
||||
}
|
||||
31
internal/handler/common.go
Normal file
31
internal/handler/common.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Success(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func SuccessMessage(c *gin.Context, message string, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": message,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, gin.H{"message": message})
|
||||
}
|
||||
|
||||
func NotImplemented(endpoint string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "not implemented yet",
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
55
internal/handler/guest_api.go
Normal file
55
internal/handler/guest_api.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GuestConfig(c *gin.Context) {
|
||||
Success(c, buildGuestConfig())
|
||||
}
|
||||
|
||||
func buildGuestConfig() gin.H {
|
||||
data := gin.H{
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
"is_email_verify": boolToInt(service.MustGetBool("email_verify", false)),
|
||||
"is_invite_force": boolToInt(service.MustGetBool("invite_force", false)),
|
||||
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
||||
"is_captcha": boolToInt(service.MustGetBool("captcha_enable", false)),
|
||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
|
||||
"recaptcha_v3_site_key": service.MustGetString("recaptcha_v3_site_key", ""),
|
||||
"recaptcha_v3_score_threshold": service.MustGetString("recaptcha_v3_score_threshold", "0.5"),
|
||||
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
|
||||
"app_description": service.MustGetString("app_description", ""),
|
||||
"app_url": service.GetAppURL(),
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"is_recaptcha": boolToInt(service.MustGetBool("captcha_enable", false)),
|
||||
}
|
||||
|
||||
if service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
data["real_name_verification_enable"] = true
|
||||
data["real_name_verification_notice"] = service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please complete real-name verification.")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func GuestPlanFetch(c *gin.Context) {
|
||||
var plans []model.Plan
|
||||
if err := database.DB.Where("`show` = ? AND sell = ?", 1, 1).Order("sort ASC").Find(&plans).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch plans")
|
||||
return
|
||||
}
|
||||
Success(c, plans)
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
410
internal/handler/node_api.go
Normal file
410
internal/handler/node_api.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func NodeShadowsocksTidalabUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "shadowsocks" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a shadowsocks node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
cipher := tidalabProtocolString(node.ProtocolSettings, "cipher")
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, gin.H{
|
||||
"id": user.ID,
|
||||
"port": node.ServerPort,
|
||||
"cipher": cipher,
|
||||
"secret": user.UUID,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
func NodeTrojanTidalabUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a trojan node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
item := gin.H{
|
||||
"id": user.ID,
|
||||
"trojan_user": gin.H{"password": user.UUID},
|
||||
}
|
||||
if user.SpeedLimit != nil {
|
||||
item["speed_limit"] = *user.SpeedLimit
|
||||
}
|
||||
if user.DeviceLimit != nil {
|
||||
item["device_limit"] = *user.DeviceLimit
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"msg": "ok",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func NodeConfig(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
config := service.BuildNodeConfig(node)
|
||||
config["base_config"] = gin.H{
|
||||
"push_interval": service.MustGetInt("server_push_interval", 60),
|
||||
"pull_interval": service.MustGetInt("server_pull_interval", 60),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func NodeTrojanTidalabConfig(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a trojan node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
localPort, err := strconv.Atoi(strings.TrimSpace(c.Query("local_port")))
|
||||
if err != nil || localPort <= 0 {
|
||||
Fail(c, http.StatusBadRequest, "local_port is required")
|
||||
return
|
||||
}
|
||||
|
||||
serverName := tidalabProtocolString(node.ProtocolSettings, "server_name")
|
||||
if serverName == "" {
|
||||
serverName = strings.TrimSpace(node.Host)
|
||||
}
|
||||
if serverName == "" {
|
||||
serverName = "domain.com"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"run_type": "server",
|
||||
"local_addr": "0.0.0.0",
|
||||
"local_port": node.ServerPort,
|
||||
"remote_addr": "www.taobao.com",
|
||||
"remote_port": 80,
|
||||
"password": []string{},
|
||||
"ssl": gin.H{
|
||||
"cert": "server.crt",
|
||||
"key": "server.key",
|
||||
"sni": serverName,
|
||||
},
|
||||
"api": gin.H{
|
||||
"enabled": true,
|
||||
"api_addr": "127.0.0.1",
|
||||
"api_port": localPort,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func NodePush(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
|
||||
var payload map[string][]int64
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for userIDRaw, traffic := range payload {
|
||||
if len(traffic) != 2 {
|
||||
continue
|
||||
}
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(userID, node, traffic[0], traffic[1])
|
||||
}
|
||||
|
||||
setNodeLastPush(node, len(payload))
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func NodeTidalabSubmit(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "shadowsocks" && node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server type is not supported by tidalab submit")
|
||||
return
|
||||
}
|
||||
|
||||
var payload []struct {
|
||||
UserID int `json:"user_id"`
|
||||
U int64 `json:"u"`
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range payload {
|
||||
if item.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(item.UserID, node, item.U, item.D)
|
||||
}
|
||||
|
||||
setNodeLastPush(node, len(payload))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ret": 1,
|
||||
"msg": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func NodeAlive(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
|
||||
var payload map[string][]string
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for userIDRaw, ips := range payload {
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = service.SetDevices(userID, node.ID, ips)
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func NodeAliveList(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.DeviceLimit != nil && *user.DeviceLimit > 0 {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
devices := service.GetUsersDevices(userIDs)
|
||||
alive := make(map[string][]string, len(devices))
|
||||
for userID, ips := range devices {
|
||||
alive[strconv.Itoa(userID)] = ips
|
||||
}
|
||||
|
||||
Success(c, gin.H{"alive": alive})
|
||||
}
|
||||
|
||||
func NodeStatus(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
var status map[string]any
|
||||
if err := c.ShouldBindJSON(&status); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
_ = database.CacheSet(nodeLoadStatusKey(node), gin.H{
|
||||
"cpu": status["cpu"],
|
||||
"mem": status["mem"],
|
||||
"swap": status["swap"],
|
||||
"disk": status["disk"],
|
||||
"kernel_status": status["kernel_status"],
|
||||
"updated_at": time.Now().Unix(),
|
||||
}, cacheTime)
|
||||
_ = database.CacheSet(nodeLastLoadKey(node), time.Now().Unix(), cacheTime)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": true, "code": 0, "message": "success"})
|
||||
}
|
||||
|
||||
func NodeHandshake(c *gin.Context) {
|
||||
websocket := gin.H{"enabled": false}
|
||||
if service.MustGetBool("server_ws_enable", true) {
|
||||
wsURL := strings.TrimSpace(service.MustGetString("server_ws_url", ""))
|
||||
if wsURL == "" {
|
||||
scheme := "ws"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "wss"
|
||||
}
|
||||
wsURL = fmt.Sprintf("%s://%s:8076", scheme, c.Request.Host)
|
||||
}
|
||||
websocket = gin.H{
|
||||
"enabled": true,
|
||||
"ws_url": strings.TrimRight(wsURL, "/"),
|
||||
}
|
||||
}
|
||||
Success(c, gin.H{"websocket": websocket})
|
||||
}
|
||||
|
||||
func NodeReport(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
var payload struct {
|
||||
Traffic map[string][]int64 `json:"traffic"`
|
||||
Alive map[string][]string `json:"alive"`
|
||||
Online map[string]int `json:"online"`
|
||||
Status map[string]any `json:"status"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.Traffic) > 0 {
|
||||
for userIDRaw, traffic := range payload.Traffic {
|
||||
if len(traffic) != 2 {
|
||||
continue
|
||||
}
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(userID, node, traffic[0], traffic[1])
|
||||
}
|
||||
setNodeLastPush(node, len(payload.Traffic))
|
||||
}
|
||||
|
||||
if len(payload.Alive) > 0 {
|
||||
for userIDRaw, ips := range payload.Alive {
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = service.SetDevices(userID, node.ID, ips)
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.Online) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
for userIDRaw, conn := range payload.Online {
|
||||
key := fmt.Sprintf("USER_ONLINE_CONN_%s_%d_%s", strings.ToUpper(node.Type), node.ID, userIDRaw)
|
||||
_ = database.CacheSet(key, conn, cacheTime)
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.Status) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
_ = database.CacheSet(nodeLoadStatusKey(node), gin.H{
|
||||
"cpu": payload.Status["cpu"],
|
||||
"mem": payload.Status["mem"],
|
||||
"swap": payload.Status["swap"],
|
||||
"disk": payload.Status["disk"],
|
||||
"kernel_status": payload.Status["kernel_status"],
|
||||
"updated_at": time.Now().Unix(),
|
||||
}, cacheTime)
|
||||
_ = database.CacheSet(nodeLastLoadKey(node), time.Now().Unix(), cacheTime)
|
||||
}
|
||||
|
||||
if len(payload.Metrics) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
payload.Metrics["updated_at"] = time.Now().Unix()
|
||||
_ = database.CacheSet(nodeMetricsKey(node), payload.Metrics, cacheTime)
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func setNodeLastCheck(node *model.Server) {
|
||||
_ = database.CacheSet(nodeLastCheckKey(node), time.Now().Unix(), time.Hour)
|
||||
}
|
||||
|
||||
func setNodeLastPush(node *model.Server, onlineUsers int) {
|
||||
_ = database.CacheSet(nodeOnlineKey(node), onlineUsers, time.Hour)
|
||||
_ = database.CacheSet(nodeLastPushKey(node), time.Now().Unix(), time.Hour)
|
||||
}
|
||||
|
||||
func nodeLastCheckKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_CHECK_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLastPushKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_PUSH_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeOnlineKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_ONLINE_USER_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLoadStatusKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LOAD_STATUS_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLastLoadKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_LOAD_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeMetricsKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_METRICS_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tidalabProtocolString(raw *string, key string) string {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decoded := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, _ := decoded[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
85
internal/handler/node_handler.go
Normal file
85
internal/handler/node_handler.go
Normal file
@@ -0,0 +1,85 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NodeUser(c *gin.Context) {
|
||||
nodePtr, _ := c.Get("node")
|
||||
_ = nodePtr.(*model.Server) // Silence unused node for now
|
||||
|
||||
var users []model.User
|
||||
// Basic filtering: banned=0 and not expired
|
||||
query := database.DB.Model(&model.User{}).
|
||||
Where("banned = ? AND (expired_at IS NULL OR expired_at > ?)", 0, time.Now().Unix())
|
||||
|
||||
if err := query.Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "获取用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
type UniUser struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
uniUsers := make([]UniUser, len(users))
|
||||
for i, u := range users {
|
||||
uniUsers[i] = UniUser{ID: u.ID, UUID: u.UUID}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": uniUsers,
|
||||
})
|
||||
}
|
||||
|
||||
func NodePush(c *gin.Context) {
|
||||
nodePtr, _ := c.Get("node")
|
||||
node := nodePtr.(*model.Server)
|
||||
|
||||
var data map[string][]int64
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
for userIDStr, traffic := range data {
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(traffic) < 2 {
|
||||
continue
|
||||
}
|
||||
u := traffic[0]
|
||||
d := traffic[1]
|
||||
|
||||
// Update user traffic
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"u": gorm.Expr("u + ?", u),
|
||||
"d": gorm.Expr("d + ?", d),
|
||||
"t": time.Now().Unix(),
|
||||
})
|
||||
|
||||
// Update node traffic
|
||||
database.DB.Model(&model.Server{}).Where("id = ?", node.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"u": gorm.Expr("u + ?", u),
|
||||
"d": gorm.Expr("d + ?", d),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": true})
|
||||
}
|
||||
|
||||
// Config, Alive, Status handlers suppressed for brevity in this step
|
||||
304
internal/handler/plugin_api.go
Normal file
304
internal/handler/plugin_api.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
query := database.DB.Model(&model.User{}).Order("id DESC")
|
||||
if keyword != "" {
|
||||
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var users []model.User
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
devices := service.GetUsersDevices(userIDs)
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
usersWithOnlineIP := 0
|
||||
totalOnlineIPs := 0
|
||||
for _, user := range users {
|
||||
subscriptionName := "No subscription"
|
||||
if user.PlanID != nil {
|
||||
var plan model.Plan
|
||||
if database.DB.First(&plan, *user.PlanID).Error == nil {
|
||||
subscriptionName = plan.Name
|
||||
}
|
||||
}
|
||||
|
||||
ips := devices[user.ID]
|
||||
if len(ips) > 0 {
|
||||
usersWithOnlineIP++
|
||||
totalOnlineIPs += len(ips)
|
||||
}
|
||||
|
||||
list = append(list, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"subscription_name": subscriptionName,
|
||||
"online_count": len(ips),
|
||||
"online_devices": ips,
|
||||
"last_online_text": formatTimeValue(user.LastOnlineAt),
|
||||
"created_text": formatUnixValue(user.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": list,
|
||||
"filters": gin.H{
|
||||
"keyword": keyword,
|
||||
"per_page": perPage,
|
||||
},
|
||||
"summary": gin.H{
|
||||
"page_users": len(users),
|
||||
"users_with_online_ip": usersWithOnlineIP,
|
||||
"total_online_ips": totalOnlineIPs,
|
||||
"current_page": page,
|
||||
},
|
||||
"pagination": gin.H{
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
devices := service.GetUsersDevices([]int{user.ID})
|
||||
ips := devices[user.ID]
|
||||
authToken, _ := c.Get("auth_token")
|
||||
currentToken, _ := authToken.(string)
|
||||
sessions := service.GetUserSessions(user.ID, currentToken)
|
||||
currentID := currentSessionID(c)
|
||||
|
||||
sessionItems := make([]gin.H, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
sessionItems = append(sessionItems, gin.H{
|
||||
"id": session.ID,
|
||||
"name": session.Name,
|
||||
"user_agent": session.UserAgent,
|
||||
"ip": firstString(session.IP, "-"),
|
||||
"created_at": session.CreatedAt,
|
||||
"last_used_at": session.LastUsedAt,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"is_current": session.ID == currentID,
|
||||
})
|
||||
}
|
||||
Success(c, gin.H{
|
||||
"ips": ips,
|
||||
"session_overview": gin.H{
|
||||
"online_ip_count": len(ips),
|
||||
"online_ips": ips,
|
||||
"online_device_count": len(ips),
|
||||
"device_limit": user.DeviceLimit,
|
||||
"last_online_at": user.LastOnlineAt,
|
||||
"active_session_count": len(sessionItems),
|
||||
"sessions": sessionItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6Check(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if user.PlanID == nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
}
|
||||
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Count(&count)
|
||||
|
||||
Success(c, gin.H{
|
||||
"allowed": service.PluginPlanAllowed(&plan),
|
||||
"is_active": count > 0,
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6Enable(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.SyncIPv6ShadowAccount(user) {
|
||||
Fail(c, 403, "your plan does not support IPv6 subscription")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
|
||||
if result.Error != nil {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||
}
|
||||
|
||||
func AdminConfigFetch(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_url": service.GetAppURL(),
|
||||
"secure_path": service.GetAdminSecurePath(),
|
||||
"server_pull_interval": service.MustGetInt("server_pull_interval", 60),
|
||||
"server_push_interval": service.MustGetInt("server_push_interval", 60),
|
||||
})
|
||||
}
|
||||
|
||||
func AdminSystemStatus(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"server_time": time.Now().Unix(),
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_url": service.GetAppURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func AdminPluginsList(c *gin.Context) {
|
||||
var plugins []model.Plugin
|
||||
if err := database.DB.Order("id ASC").Find(&plugins).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch plugins")
|
||||
return
|
||||
}
|
||||
Success(c, plugins)
|
||||
}
|
||||
|
||||
func AdminPluginTypes(c *gin.Context) {
|
||||
Success(c, []string{"feature", "payment"})
|
||||
}
|
||||
|
||||
func AdminPluginIntegrationStatus(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"user_online_devices": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginUserOnlineDevices),
|
||||
"status": "complete",
|
||||
"summary": "用户侧在线设备概览与后台设备监控已接入 Go 后端。",
|
||||
"endpoints": []string{"/api/v1/user/user-online-devices/get-ip", "/api/v1/" + service.GetAdminSecurePath() + "/user-online-devices/users"},
|
||||
},
|
||||
"real_name_verification": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginRealNameVerification),
|
||||
"status": "complete",
|
||||
"summary": "实名状态查询、提交、后台审核与批量同步均已整合,并补齐 auto_approve/allow_resubmit_after_reject 行为。",
|
||||
"endpoints": []string{"/api/v1/user/real-name-verification/status", "/api/v1/user/real-name-verification/submit", "/api/v1/" + service.GetAdminSecurePath() + "/realname/records"},
|
||||
},
|
||||
"user_add_ipv6_subscription": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginUserAddIPv6),
|
||||
"status": "integrated_with_runtime_sync",
|
||||
"summary": "用户启用、密码同步与运行时影子账号同步已接入;订单生命周期自动钩子仍依赖后续完整订单流重构。",
|
||||
"endpoints": []string{"/api/v1/user/user-add-ipv6-subscription/check", "/api/v1/user/user-add-ipv6-subscription/enable", "/api/v1/user/user-add-ipv6-subscription/sync-password"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, defaultValue int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func calculateLastPage(total int64, perPage int) int {
|
||||
if perPage <= 0 {
|
||||
return 1
|
||||
}
|
||||
last := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
if last == 0 {
|
||||
return 1
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func formatUnixValue(value int64) string {
|
||||
if value <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return time.Unix(value, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatTimeValue(value *time.Time) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return value.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
426
internal/handler/realname_api.go
Normal file
426
internal/handler/realname_api.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const unverifiedExpiration = int64(946684800)
|
||||
|
||||
func PluginRealNameStatus(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
auth := realNameRecordForUser(user)
|
||||
status := "unverified"
|
||||
var realName, masked, rejectReason string
|
||||
var submittedAt, reviewedAt int64
|
||||
if auth != nil {
|
||||
status = auth.Status
|
||||
realName = auth.RealName
|
||||
masked = auth.IdentityMasked
|
||||
rejectReason = auth.RejectReason
|
||||
submittedAt = auth.SubmittedAt
|
||||
reviewedAt = auth.ReviewedAt
|
||||
}
|
||||
canSubmit := status == "unverified" || (status == "rejected" && service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true))
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": status,
|
||||
"status_label": realNameStatusLabel(status),
|
||||
"is_inherited": strings.Contains(user.Email, "-ipv6@"),
|
||||
"can_submit": canSubmit,
|
||||
"real_name": realName,
|
||||
"identity_no_masked": masked,
|
||||
"notice": service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please submit real-name information."),
|
||||
"reject_reason": rejectReason,
|
||||
"submitted_at": submittedAt,
|
||||
"reviewed_at": reviewedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func PluginRealNameSubmit(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
IdentityNo string `json:"identity_no" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 422, "missing identity information")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
record := model.RealNameAuth{
|
||||
UserID: uint64(user.ID),
|
||||
RealName: req.RealName,
|
||||
IdentityMasked: maskIdentity(req.IdentityNo),
|
||||
IdentityEncrypted: req.IdentityNo,
|
||||
Status: "pending",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
var existing model.RealNameAuth
|
||||
status := "pending"
|
||||
reviewedAt := int64(0)
|
||||
if service.GetPluginConfigBool(service.PluginRealNameVerification, "auto_approve", false) {
|
||||
status = "approved"
|
||||
reviewedAt = now
|
||||
}
|
||||
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
|
||||
if existing.Status == "approved" {
|
||||
Fail(c, 400, "verification already approved")
|
||||
return
|
||||
}
|
||||
if existing.Status == "pending" {
|
||||
Fail(c, 400, "verification is pending review")
|
||||
return
|
||||
}
|
||||
if existing.Status == "rejected" && !service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true) {
|
||||
Fail(c, 400, "rejected records cannot be resubmitted")
|
||||
return
|
||||
}
|
||||
|
||||
record.ID = existing.ID
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
record.Status = status
|
||||
record.ReviewedAt = reviewedAt
|
||||
record.RejectReason = ""
|
||||
database.DB.Model(&existing).Updates(record)
|
||||
} else {
|
||||
record.Status = status
|
||||
record.ReviewedAt = reviewedAt
|
||||
database.DB.Create(&record)
|
||||
}
|
||||
|
||||
syncRealNameExpiration(user.ID, status)
|
||||
SuccessMessage(c, "verification submitted", gin.H{"status": status})
|
||||
}
|
||||
|
||||
func PluginRealNameRecords(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
query := database.DB.Table("v2_user AS u").
|
||||
Select("u.id, u.email, r.status, r.real_name, r.identity_masked, r.submitted_at, r.reviewed_at").
|
||||
Joins("LEFT JOIN v2_realname_auth AS r ON u.id = r.user_id").
|
||||
Where("u.email NOT LIKE ?", "%-ipv6@%")
|
||||
if keyword != "" {
|
||||
query = query.Where("u.email LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
type recordRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Status *string
|
||||
RealName *string
|
||||
IdentityMasked *string
|
||||
SubmittedAt *int64
|
||||
ReviewedAt *int64
|
||||
}
|
||||
|
||||
var rows []recordRow
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Scan(&rows).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch records")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
status := "unverified"
|
||||
if row.Status != nil && *row.Status != "" {
|
||||
status = *row.Status
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": row.ID,
|
||||
"email": row.Email,
|
||||
"status": status,
|
||||
"status_label": realNameStatusLabel(status),
|
||||
"real_name": stringPointerValue(row.RealName),
|
||||
"identity_no_masked": stringPointerValue(row.IdentityMasked),
|
||||
"submitted_at": int64PointerValue(row.SubmittedAt),
|
||||
"reviewed_at": int64PointerValue(row.ReviewedAt),
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": "success",
|
||||
"data": items,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginRealNameReview(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Status == "" {
|
||||
req.Status = "approved"
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", userID).First(&record).Error; err == nil {
|
||||
record.Status = req.Status
|
||||
record.RejectReason = req.Reason
|
||||
record.ReviewedAt = now
|
||||
record.UpdatedAt = now
|
||||
database.DB.Save(&record)
|
||||
} else {
|
||||
database.DB.Create(&model.RealNameAuth{
|
||||
UserID: uint64(userID),
|
||||
Status: req.Status,
|
||||
RealName: "admin approved",
|
||||
IdentityMasked: "admin approved",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: now,
|
||||
RejectReason: req.Reason,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
syncRealNameExpiration(userID, req.Status)
|
||||
SuccessMessage(c, "review updated", true)
|
||||
}
|
||||
|
||||
func PluginRealNameReset(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Where("user_id = ?", userID).Delete(&model.RealNameAuth{})
|
||||
syncRealNameExpiration(userID, "unverified")
|
||||
SuccessMessage(c, "record reset", true)
|
||||
}
|
||||
|
||||
func PluginRealNameSyncAll(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
|
||||
}
|
||||
|
||||
func PluginRealNameApproveAll(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)
|
||||
now := time.Now().Unix()
|
||||
for _, user := range users {
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
||||
record.Status = "approved"
|
||||
record.RealName = "admin approved"
|
||||
record.IdentityMasked = "admin approved"
|
||||
record.SubmittedAt = now
|
||||
record.ReviewedAt = now
|
||||
record.UpdatedAt = now
|
||||
database.DB.Save(&record)
|
||||
} else {
|
||||
database.DB.Create(&model.RealNameAuth{
|
||||
UserID: uint64(user.ID),
|
||||
Status: "approved",
|
||||
RealName: "admin approved",
|
||||
IdentityMasked: "admin approved",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage(c, "all users approved", performGlobalRealNameSync())
|
||||
}
|
||||
|
||||
func PluginRealNameClearCache(c *gin.Context) {
|
||||
_ = database.CacheDelete("realname:sync")
|
||||
SuccessMessage(c, "cache cleared", true)
|
||||
}
|
||||
|
||||
func realNameRecordForUser(user *model.User) *model.RealNameAuth {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
||||
return &record
|
||||
}
|
||||
|
||||
if strings.Contains(user.Email, "-ipv6@") {
|
||||
mainEmail := strings.Replace(user.Email, "-ipv6@", "@", 1)
|
||||
var mainUser model.User
|
||||
if err := database.DB.Where("email = ?", mainEmail).First(&mainUser).Error; err == nil {
|
||||
if err := database.DB.Where("user_id = ?", mainUser.ID).First(&record).Error; err == nil {
|
||||
return &record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncRealNameExpiration(userID int, status string) {
|
||||
if status == "approved" {
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"expired_at": parseExpirationSetting(),
|
||||
"updated_at": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false) {
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Update("updated_at", time.Now().Unix())
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"expired_at": unverifiedExpiration,
|
||||
"updated_at": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseExpirationSetting() int64 {
|
||||
raw := service.GetPluginConfigString(service.PluginRealNameVerification, "verified_expiration_date", "2099-12-31")
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return value
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
return parsed.Unix()
|
||||
}
|
||||
return time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
|
||||
}
|
||||
|
||||
func performGlobalRealNameSync() gin.H {
|
||||
expireApproved := parseExpirationSetting()
|
||||
now := time.Now().Unix()
|
||||
enforce := service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false)
|
||||
|
||||
type approvedRow struct {
|
||||
UserID int
|
||||
}
|
||||
var approvedRows []approvedRow
|
||||
database.DB.Table("v2_realname_auth").Select("user_id").Where("status = ?", "approved").Scan(&approvedRows)
|
||||
approvedUsers := make([]int, 0, len(approvedRows))
|
||||
for _, row := range approvedRows {
|
||||
approvedUsers = append(approvedUsers, row.UserID)
|
||||
}
|
||||
|
||||
if len(approvedUsers) > 0 {
|
||||
database.DB.Model(&model.User{}).Where("id IN ?", approvedUsers).
|
||||
Updates(map[string]any{"expired_at": expireApproved, "updated_at": now})
|
||||
if enforce {
|
||||
database.DB.Model(&model.User{}).Where("id NOT IN ?", approvedUsers).
|
||||
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
||||
}
|
||||
} else {
|
||||
if enforce {
|
||||
database.DB.Model(&model.User{}).
|
||||
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
||||
}
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"enforce_real_name": enforce,
|
||||
"total_verified": len(approvedUsers),
|
||||
"actual_synced": len(approvedUsers),
|
||||
}
|
||||
}
|
||||
|
||||
func realNameStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "approved":
|
||||
return "approved"
|
||||
case "pending":
|
||||
return "pending"
|
||||
case "rejected":
|
||||
return "rejected"
|
||||
default:
|
||||
return "unverified"
|
||||
}
|
||||
}
|
||||
|
||||
func maskIdentity(identity string) string {
|
||||
if len(identity) <= 8 {
|
||||
return identity
|
||||
}
|
||||
return identity[:4] + "**********" + identity[len(identity)-4:]
|
||||
}
|
||||
|
||||
func stringPointerValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func int64PointerValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
269
internal/handler/realname_handler.go
Normal file
269
internal/handler/realname_handler.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RealNameIndex renders the beautified plugin management page.
|
||||
func RealNameIndex(c *gin.Context) {
|
||||
var appNameSetting model.Setting
|
||||
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
|
||||
appName := appNameSetting.Value
|
||||
if appName == "" {
|
||||
appName = "XBoard"
|
||||
}
|
||||
|
||||
securePath := c.Param("path")
|
||||
apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath)
|
||||
reviewEndpoint := fmt.Sprintf("/api/v1/%%s/realname/review", securePath)
|
||||
|
||||
// We use %% for literal percent signs in Sprintf
|
||||
// and we avoid backticks in the JS code by using regular strings to remain compatible with Go raw strings.
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%%s - 实名验证管理</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0f172a;
|
||||
--bg-accent: #1e293b;
|
||||
--primary: #6366f1;
|
||||
--secondary: #a855f7;
|
||||
--text-main: #f8fafc;
|
||||
--text-dim: #94a3b8;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Outfit', sans-serif; }
|
||||
body { background: var(--bg-deep); color: var(--text-main); min-height: 100vh; overflow-x: hidden; position: relative; padding: 40px 20px; }
|
||||
|
||||
body::before {
|
||||
content: ''; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: radial-gradient(circle at 10%% 10%%, rgba(99, 102, 241, 0.1), transparent 40%%),
|
||||
radial-gradient(circle at 90%% 90%%, rgba(168, 85, 247, 0.1), transparent 40%%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; animation: fadeIn 0.8s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 40px; }
|
||||
h1 { font-size: 2.5rem; font-weight: 600; background: linear-gradient(135deg, var(--primary), var(--secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header p { color: var(--text-dim); font-size: 1.1rem; margin-top: 8px; }
|
||||
|
||||
.main-card { background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(20px); border-radius: 24px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
|
||||
|
||||
.toolbar { padding: 24px 32px; border-bottom: 1px solid var(--glass-border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-box input { background: var(--bg-accent); border: 1px solid var(--glass-border); color: white; padding: 12px 20px; border-radius: 12px; font-size: 14px; width: 300px; outline: none; transition: border-color 0.3s; }
|
||||
.search-box input:focus { border-color: var(--primary); }
|
||||
|
||||
table { width: 100%%%%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 16px 32px; font-size: 0.8rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--glass-border); }
|
||||
td { padding: 20px 32px; border-bottom: 1px solid var(--glass-border); font-size: 0.95rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
|
||||
.badge { padding: 6px 12px; border-radius: 100px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge.approved { background: rgba(16, 185, 129, 0.1); color: var(--success); }
|
||||
.badge.pending { background: rgba(245, 158, 11, 0.1); color: var(--warning); }
|
||||
.badge.rejected { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||
|
||||
.btn { padding: 8px 16px; border-radius: 10px; font-weight: 600; font-size: 0.85rem; border: none; cursor: pointer; transition: 0.3s; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: #5a5ce5; transform: translateY(-2px); }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: white; }
|
||||
|
||||
.pagination { padding: 24px 32px; display: flex; justify-content: space-between; align-items: center; color: var(--text-dim); font-size: 0.9rem; }
|
||||
.btn-nav { background: var(--bg-accent); color: white; padding: 8px 16px; border-radius: 8px; font-weight: 600; }
|
||||
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
#toast-box { position: fixed; top: 20px; right: 20px; background: var(--bg-accent); border: 1px solid var(--glass-border); padding: 12px 24px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); transform: translateX(200%%); transition: 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 1000; }
|
||||
#toast-box.show { transform: translateX(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>实名验证管理</h1>
|
||||
<p>集中处理全站用户的身份验证申请</p>
|
||||
</div>
|
||||
<a href="/%%s" class="btn btn-primary" style="background: var(--bg-accent)">返回控制台</a>
|
||||
</header>
|
||||
|
||||
<div class="main-card">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="keyword" placeholder="搜邮箱或姓名..." onkeypress="if(event.keyCode==13) loadData(1)">
|
||||
</div>
|
||||
<div id="status-label" style="font-weight: 600">正在获取数据...</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户 ID</th>
|
||||
<th>邮箱</th>
|
||||
<th>真实姓名</th>
|
||||
<th>认证状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<!-- Rows will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="btn-nav" id="prev-btn" onclick="loadData(state.page - 1)">上一页</button>
|
||||
<div id="page-label">第 1 / 1 页</div>
|
||||
<button class="btn-nav" id="next-btn" onclick="loadData(state.page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast-box">操作成功</div>
|
||||
|
||||
<script>
|
||||
const state = { page: 1, lastPage: 1 };
|
||||
const api = "%%s";
|
||||
const reviewApi = "%%s";
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast-box');
|
||||
t.innerText = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
async function loadData(page = 1) {
|
||||
state.page = page;
|
||||
const keyword = document.getElementById('keyword').value;
|
||||
try {
|
||||
const res = await fetch(api + '?page=' + page + '&keyword=' + keyword);
|
||||
const data = await res.json();
|
||||
const list = data.data || [];
|
||||
state.lastPage = data.pagination.last_page;
|
||||
|
||||
document.getElementById('page-label').innerText = '第 ' + page + ' / ' + state.lastPage + ' 页';
|
||||
document.getElementById('status-label').innerText = '共计 ' + data.pagination.total + ' 条记录';
|
||||
|
||||
let html = '';
|
||||
list.forEach(item => {
|
||||
const statusLabel = item.status === 'approved' ? '已通过' : (item.status === 'pending' ? '待审核' : '已驳回');
|
||||
html += '<tr>' +
|
||||
'<td><span style="opacity: 0.5">#</span>' + item.user_id + '</td>' +
|
||||
'<td>' + item.user.email + '</td>' +
|
||||
'<td>' + (item.real_name || '-') + '</td>' +
|
||||
'<td><span class="badge ' + item.status + '">' + statusLabel + '</span></td>' +
|
||||
'<td>' +
|
||||
(item.status === 'pending' ? '<button class="btn btn-primary" onclick="review(' + item.id + ', \\'approved\\')">通过</button> ' : '') +
|
||||
'<button class="btn btn-danger" onclick="review(' + item.id + ', \\'rejected\\')">驳回</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
document.getElementById('table-body').innerHTML = html || '<tr><td colspan="5" style="text-align:center; padding:100px; opacity:0.3">暂无数据</td></tr>';
|
||||
|
||||
document.getElementById('prev-btn').disabled = page <= 1;
|
||||
document.getElementById('next-btn').disabled = page >= state.lastPage;
|
||||
} catch (e) { console.error(e); showToast('加载失败'); }
|
||||
}
|
||||
|
||||
async function review(id, status) {
|
||||
try {
|
||||
const res = await fetch(reviewApi + '/' + id, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
const data = await res.json();
|
||||
showToast(data.message || '操作成功');
|
||||
loadData(state.page);
|
||||
} catch (e) { showToast('操作失败'); }
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`, appName, securePath, apiEndpoint, reviewEndpoint)
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
// RealNameRecords handles the listing of authentication records.
|
||||
func RealNameRecords(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize := 15
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var records []model.RealNameAuth
|
||||
var total int64
|
||||
|
||||
query := database.DB.Preload("User").Model(&model.RealNameAuth{})
|
||||
if keyword != "" {
|
||||
query = query.Joins("JOIN v2_user ON v2_user.id = v2_realname_auth.user_id").
|
||||
Where("v2_user.email LIKE ?", "%%"+keyword+"%%")
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("created_at DESC").Find(&records)
|
||||
|
||||
lastPage := (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": records,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"current": page,
|
||||
"last_page": lastPage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RealNameReview handles approval or rejection of a record.
|
||||
func RealNameReview(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.First(&record, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
record.Status = req.Status
|
||||
record.ReviewedAt = time.Now().Unix()
|
||||
database.DB.Save(&record)
|
||||
|
||||
// Sync User Expiration if approved
|
||||
if req.Status == "approved" {
|
||||
// Set a long expiration date (e.g., 2099-12-31)
|
||||
expiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
|
||||
database.DB.Model(&model.User{}).Where("id = ?", record.UserID).Update("expired_at", expiry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "审核操作成功"})
|
||||
}
|
||||
71
internal/handler/subscribe_handler.go
Normal file
71
internal/handler/subscribe_handler.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/protocol"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Subscribe(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "缺少Token"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "无效的Token"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "账号已被封禁"})
|
||||
return
|
||||
}
|
||||
|
||||
var servers []model.Server
|
||||
query := database.DB.Where("`show` = ?", 1).Order("sort ASC")
|
||||
if err := query.Find(&servers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "获取节点失败"})
|
||||
return
|
||||
}
|
||||
|
||||
ua := c.GetHeader("User-Agent")
|
||||
flag := c.Query("flag")
|
||||
|
||||
if strings.Contains(ua, "Clash") || flag == "clash" {
|
||||
config, _ := protocol.GenerateClash(servers, user)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "sing-box") || flag == "sing-box" {
|
||||
config, _ := protocol.GenerateSingBox(servers, user)
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: VMess/SS link format (Base64)
|
||||
var links []string
|
||||
for _, s := range servers {
|
||||
// Mocked link for now
|
||||
link := fmt.Sprintf("vmess://%s", s.Name)
|
||||
links = append(links, link)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n")))
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Subscription-Userinfo", fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", user.U, user.D, user.TransferEnable, user.ExpiredAt))
|
||||
c.String(http.StatusOK, encoded)
|
||||
}
|
||||
259
internal/handler/user_api.go
Normal file
259
internal/handler/user_api.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func UserInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"email": user.Email,
|
||||
"transfer_enable": user.TransferEnable,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"created_at": user.CreatedAt,
|
||||
"banned": user.Banned,
|
||||
"remind_expire": user.RemindExpire,
|
||||
"remind_traffic": user.RemindTraffic,
|
||||
"expired_at": user.ExpiredAt,
|
||||
"balance": user.Balance,
|
||||
"commission_balance": user.CommissionBalance,
|
||||
"plan_id": user.PlanID,
|
||||
"discount": user.Discount,
|
||||
"commission_rate": user.CommissionRate,
|
||||
"telegram_id": user.TelegramID,
|
||||
"uuid": user.UUID,
|
||||
"avatar_url": "https://cdn.v2ex.com/gravatar/" + fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(user.Email)))) + "?s=64&d=identicon",
|
||||
})
|
||||
}
|
||||
|
||||
func UserGetStat(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var pendingOrders int64
|
||||
var openTickets int64
|
||||
var invitedUsers int64
|
||||
|
||||
database.DB.Model(&model.Order{}).Where("status = ? AND user_id = ?", 0, user.ID).Count(&pendingOrders)
|
||||
database.DB.Model(&model.Ticket{}).Where("status = ? AND user_id = ?", 0, user.ID).Count(&openTickets)
|
||||
database.DB.Model(&model.User{}).Where("invite_user_id = ?", user.ID).Count(&invitedUsers)
|
||||
|
||||
Success(c, []int64{pendingOrders, openTickets, invitedUsers})
|
||||
}
|
||||
|
||||
func UserGetSubscribe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if user.PlanID != nil {
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err == nil {
|
||||
user.Plan = &plan
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(service.GetAppURL(), "/")
|
||||
if baseURL == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"plan_id": user.PlanID,
|
||||
"token": user.Token,
|
||||
"expired_at": user.ExpiredAt,
|
||||
"u": user.U,
|
||||
"d": user.D,
|
||||
"transfer_enable": user.TransferEnable,
|
||||
"email": user.Email,
|
||||
"uuid": user.UUID,
|
||||
"device_limit": user.DeviceLimit,
|
||||
"speed_limit": user.SpeedLimit,
|
||||
"next_reset_at": user.NextResetAt,
|
||||
"plan": user.Plan,
|
||||
"subscribe_url": strings.TrimRight(baseURL, "/") + "/api/v1/client/subscribe?token=" + user.Token,
|
||||
"reset_day": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func UserCheckLogin(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Success(c, gin.H{"is_login": false})
|
||||
return
|
||||
}
|
||||
Success(c, gin.H{
|
||||
"is_login": true,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func UserResetSecurity(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
newUUID := uuid.New().String()
|
||||
newToken := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+user.Email)))[:16]
|
||||
if err := database.DB.Model(&model.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Updates(map[string]any{"uuid": newUUID, "token": newToken, "updated_at": time.Now().Unix()}).Error; err != nil {
|
||||
Fail(c, 500, "reset failed")
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(service.GetAppURL(), "/")
|
||||
if baseURL == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = scheme + "://" + c.Request.Host
|
||||
}
|
||||
Success(c, strings.TrimRight(baseURL, "/")+"/api/v1/client/subscribe?token="+newToken)
|
||||
}
|
||||
|
||||
func UserUpdate(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RemindExpire *int `json:"remind_expire"`
|
||||
RemindTraffic *int `json:"remind_traffic"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{"updated_at": time.Now().Unix()}
|
||||
if req.RemindExpire != nil {
|
||||
updates["remind_expire"] = *req.RemindExpire
|
||||
}
|
||||
if req.RemindTraffic != nil {
|
||||
updates["remind_traffic"] = *req.RemindTraffic
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||
Fail(c, 500, "save failed")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func UserChangePassword(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.OldPassword, user.Password, user.PasswordAlgo, user.PasswordSalt) {
|
||||
Fail(c, 400, "the old password is wrong")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := utils.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&model.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"password_algo": nil,
|
||||
"password_salt": nil,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}).Error; err != nil {
|
||||
Fail(c, 500, "save failed")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func UserServerFetch(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
Success(c, servers)
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (*model.User, bool) {
|
||||
if value, exists := c.Get("user"); exists {
|
||||
if user, ok := value.(*model.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
|
||||
userIDValue, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
userID, ok := userIDValue.(int)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && !strings.Contains(user.Email, "-ipv6@") && user.PlanID != nil {
|
||||
service.SyncIPv6ShadowAccount(&user)
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
return &user, true
|
||||
}
|
||||
1067
internal/handler/user_extra_api.go
Normal file
1067
internal/handler/user_extra_api.go
Normal file
File diff suppressed because it is too large
Load Diff
25
internal/handler/user_handler.go
Normal file
25
internal/handler/user_handler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserInfo(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": user,
|
||||
})
|
||||
}
|
||||
313
internal/handler/user_support_api.go
Normal file
313
internal/handler/user_support_api.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserKnowledgeFetch(c *gin.Context) {
|
||||
language := strings.TrimSpace(c.DefaultQuery("language", "zh-CN"))
|
||||
query := database.DB.Model(&model.Knowledge{}).Where("`show` = ?", 1).Order("sort ASC, id ASC")
|
||||
if language != "" {
|
||||
query = query.Where("language = ? OR language = ''", language)
|
||||
}
|
||||
|
||||
var articles []model.Knowledge
|
||||
if err := query.Find(&articles).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch knowledge articles")
|
||||
return
|
||||
}
|
||||
Success(c, articles)
|
||||
}
|
||||
|
||||
func UserKnowledgeCategories(c *gin.Context) {
|
||||
var categories []string
|
||||
if err := database.DB.Model(&model.Knowledge{}).
|
||||
Where("`show` = ?", 1).
|
||||
Distinct().
|
||||
Order("category ASC").
|
||||
Pluck("category", &categories).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch knowledge categories")
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(categories))
|
||||
for _, category := range categories {
|
||||
category = strings.TrimSpace(category)
|
||||
if category != "" && !slices.Contains(filtered, category) {
|
||||
filtered = append(filtered, category)
|
||||
}
|
||||
}
|
||||
Success(c, filtered)
|
||||
}
|
||||
|
||||
func UserTicketFetch(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" {
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, user.ID).First(&ticket).Error; err != nil {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
var messages []model.TicketMessage
|
||||
_ = database.DB.Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error
|
||||
|
||||
payload := gin.H{
|
||||
"id": ticket.ID,
|
||||
"user_id": ticket.UserID,
|
||||
"subject": ticket.Subject,
|
||||
"level": ticket.Level,
|
||||
"status": ticket.Status,
|
||||
"reply_status": ticket.ReplyStatus,
|
||||
"created_at": ticket.CreatedAt,
|
||||
"updated_at": ticket.UpdatedAt,
|
||||
"message": buildTicketMessages(messages, user.ID),
|
||||
}
|
||||
Success(c, payload)
|
||||
return
|
||||
}
|
||||
|
||||
var tickets []model.Ticket
|
||||
if err := database.DB.Where("user_id = ?", user.ID).Order("updated_at DESC, id DESC").Find(&tickets).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch tickets")
|
||||
return
|
||||
}
|
||||
Success(c, tickets)
|
||||
}
|
||||
|
||||
func UserTicketSave(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject" binding:"required"`
|
||||
Level int `json:"level"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid ticket payload")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
ticket := model.Ticket{
|
||||
UserID: user.ID,
|
||||
Subject: strings.TrimSpace(req.Subject),
|
||||
Level: req.Level,
|
||||
Status: 0,
|
||||
ReplyStatus: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&ticket).Error; err != nil {
|
||||
Fail(c, 500, "failed to create ticket")
|
||||
return
|
||||
}
|
||||
|
||||
message := model.TicketMessage{
|
||||
UserID: user.ID,
|
||||
TicketID: ticket.ID,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := database.DB.Create(&message).Error; err != nil {
|
||||
Fail(c, 500, "failed to save ticket message")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "ticket created", true)
|
||||
}
|
||||
|
||||
func UserTicketReply(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid ticket reply payload")
|
||||
return
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", req.ID, user.ID).First(&ticket).Error; err != nil {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
if ticket.Status != 0 {
|
||||
Fail(c, 400, "ticket is closed")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
reply := model.TicketMessage{
|
||||
UserID: user.ID,
|
||||
TicketID: ticket.ID,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := database.DB.Create(&reply).Error; err != nil {
|
||||
Fail(c, 500, "failed to save ticket reply")
|
||||
return
|
||||
}
|
||||
|
||||
_ = database.DB.Model(&model.Ticket{}).
|
||||
Where("id = ?", ticket.ID).
|
||||
Updates(map[string]any{"reply_status": 1, "updated_at": now}).Error
|
||||
|
||||
SuccessMessage(c, "ticket replied", true)
|
||||
}
|
||||
|
||||
func UserTicketClose(c *gin.Context) {
|
||||
updateTicketStatus(c, 1, "ticket closed")
|
||||
}
|
||||
|
||||
func UserTicketWithdraw(c *gin.Context) {
|
||||
updateTicketStatus(c, 1, "ticket withdrawn")
|
||||
}
|
||||
|
||||
func UserGetActiveSession(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
authToken, _ := c.Get("auth_token")
|
||||
currentToken, _ := authToken.(string)
|
||||
sessions := service.GetUserSessions(user.ID, currentToken)
|
||||
payload := make([]gin.H, 0, len(sessions))
|
||||
currentSessionID := currentSessionID(c)
|
||||
|
||||
for _, session := range sessions {
|
||||
payload = append(payload, gin.H{
|
||||
"id": session.ID,
|
||||
"name": session.Name,
|
||||
"user_agent": session.UserAgent,
|
||||
"ip": firstString(session.IP, "-"),
|
||||
"created_at": session.CreatedAt,
|
||||
"last_used_at": session.LastUsedAt,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"is_current": session.ID == currentSessionID,
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, payload)
|
||||
}
|
||||
|
||||
func UserRemoveActiveSession(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "session_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.RemoveUserSession(user.ID, req.SessionID) {
|
||||
Fail(c, 404, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "session removed", true)
|
||||
}
|
||||
|
||||
func updateTicketStatus(c *gin.Context, status int, message string) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "ticket id is required")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
result := database.DB.Model(&model.Ticket{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, user.ID).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
Fail(c, 500, "failed to update ticket")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, message, true)
|
||||
}
|
||||
|
||||
func buildTicketMessages(messages []model.TicketMessage, currentUserID int) []gin.H {
|
||||
payload := make([]gin.H, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
payload = append(payload, gin.H{
|
||||
"id": message.ID,
|
||||
"user_id": message.UserID,
|
||||
"message": message.Message,
|
||||
"created_at": message.CreatedAt,
|
||||
"updated_at": message.UpdatedAt,
|
||||
"is_me": message.UserID == currentUserID,
|
||||
})
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func currentSessionID(c *gin.Context) string {
|
||||
value, exists := c.Get("session")
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
session, ok := value.(service.SessionRecord)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return session.ID
|
||||
}
|
||||
|
||||
func firstString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
104
internal/handler/web_pages.go
Normal file
104
internal/handler/web_pages.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type userThemeViewData struct {
|
||||
Title string
|
||||
Description string
|
||||
Version string
|
||||
Theme string
|
||||
Logo string
|
||||
AssetsPath string
|
||||
CustomHTML template.HTML
|
||||
ThemeConfigJSON template.JS
|
||||
}
|
||||
|
||||
type adminAppViewData struct {
|
||||
Title string
|
||||
ConfigJSON template.JS
|
||||
}
|
||||
|
||||
func UserThemePage(c *gin.Context) {
|
||||
config := map[string]any{
|
||||
"accent": service.MustGetString("nebula_theme_color", "aurora"),
|
||||
"slogan": service.MustGetString("nebula_hero_slogan", "One control center for login, subscriptions, sessions, and device visibility."),
|
||||
"backgroundUrl": service.MustGetString("nebula_background_url", ""),
|
||||
"metricsBaseUrl": service.MustGetString("nebula_metrics_base_url", ""),
|
||||
"defaultThemeMode": service.MustGetString("nebula_default_theme_mode", "system"),
|
||||
"lightLogoUrl": service.MustGetString("nebula_light_logo_url", ""),
|
||||
"darkLogoUrl": service.MustGetString("nebula_dark_logo_url", ""),
|
||||
"welcomeTarget": service.MustGetString("nebula_welcome_target", service.MustGetString("app_name", "XBoard")),
|
||||
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
|
||||
"icpNo": service.MustGetString("icp_no", ""),
|
||||
"psbNo": service.MustGetString("psb_no", ""),
|
||||
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
|
||||
}
|
||||
|
||||
payload := userThemeViewData{
|
||||
Title: service.MustGetString("app_name", "XBoard"),
|
||||
Description: service.MustGetString("app_description", "Go rebuilt control panel"),
|
||||
Version: service.MustGetString("app_version", "2.0.0"),
|
||||
Theme: "Nebula",
|
||||
Logo: service.MustGetString("logo", ""),
|
||||
AssetsPath: "/theme/Nebula/assets",
|
||||
CustomHTML: template.HTML(service.MustGetString("nebula_custom_html", "")),
|
||||
ThemeConfigJSON: mustJSON(config),
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "user_nebula.html"), payload)
|
||||
}
|
||||
|
||||
func AdminAppPage(c *gin.Context) {
|
||||
securePath := service.GetAdminSecurePath()
|
||||
config := map[string]any{
|
||||
"title": service.MustGetString("app_name", "XBoard") + " Admin",
|
||||
"securePath": securePath,
|
||||
"api": map[string]string{
|
||||
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
|
||||
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
|
||||
"plugins": "/api/v2/" + securePath + "/plugin/getPlugins",
|
||||
"integration": "/api/v2/" + securePath + "/plugin/integration-status",
|
||||
"realnameBase": "/api/v1/" + securePath + "/realname",
|
||||
"onlineDevices": "/api/v1/" + securePath + "/user-online-devices/users",
|
||||
},
|
||||
}
|
||||
payload := adminAppViewData{
|
||||
Title: config["title"].(string),
|
||||
ConfigJSON: mustJSON(config),
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)
|
||||
}
|
||||
|
||||
func renderPageTemplate(c *gin.Context, templatePath string, data any) {
|
||||
tpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "template parse error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "template render error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
func mustJSON(value any) template.JS {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return template.JS("{}")
|
||||
}
|
||||
return template.JS(payload)
|
||||
}
|
||||
52
internal/middleware/auth.go
Normal file
52
internal/middleware/auth.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "未登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "无效的认证格式"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "登录已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isAdmin, exists := c.Get("is_admin")
|
||||
if !exists || !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "权限不足"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
86
internal/middleware/auth_v2.go
Normal file
86
internal/middleware/auth_v2.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "token expired or invalid"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if service.IsSessionTokenRevoked(parts[1]) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "session has been revoked"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
c.Set("auth_token", parts[1])
|
||||
c.Set("session", service.TrackSession(claims.UserID, parts[1], c.ClientIP(), c.GetHeader("User-Agent")))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isAdmin, exists := c.Get("is_admin")
|
||||
if !exists || !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ClientAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Param("token")
|
||||
}
|
||||
if token == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "token is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("is_admin", user.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
50
internal/middleware/node_auth.go
Normal file
50
internal/middleware/node_auth.go
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build ignore
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
nodeID := c.Query("node_id")
|
||||
nodeType := c.Query("node_type")
|
||||
|
||||
if token == "" || nodeID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "缺少认证信息"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check server_token from settings
|
||||
var setting model.Setting
|
||||
if err := database.DB.Where("name = ?", "server_token").First(&setting).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "系统配置错误"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if token != setting.Value {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "无效的Token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Get node info
|
||||
var node model.Server
|
||||
if err := database.DB.Where("id = ? AND type = ?", nodeID, nodeType).First(&node).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "节点不存在"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("node", &node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
51
internal/middleware/node_auth_v2.go
Normal file
51
internal/middleware/node_auth_v2.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
nodeID := c.Query("node_id")
|
||||
nodeType := service.NormalizeServerType(c.Query("node_type"))
|
||||
|
||||
if token == "" || nodeID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "missing node credentials"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !service.IsValidServerType(nodeType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "invalid node type"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
serverToken := service.MustGetString("server_token", "")
|
||||
if serverToken == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "server_token is not configured"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if token != serverToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid server token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
node, err := service.FindServer(nodeID, nodeType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "server not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("node", node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
16
internal/model/commission_log.go
Normal file
16
internal/model/commission_log.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
type CommissionLog struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID *int `gorm:"column:user_id" json:"user_id"`
|
||||
InviteUserID int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
TradeNo string `gorm:"column:trade_no" json:"trade_no"`
|
||||
OrderAmount int64 `gorm:"column:order_amount" json:"order_amount"`
|
||||
GetAmount int64 `gorm:"column:get_amount" json:"get_amount"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CommissionLog) TableName() string {
|
||||
return "v2_commission_log"
|
||||
}
|
||||
22
internal/model/coupon.go
Normal file
22
internal/model/coupon.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
type Coupon struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Type int `gorm:"column:type" json:"type"`
|
||||
Value int64 `gorm:"column:value" json:"value"`
|
||||
LimitPlanIDs *string `gorm:"column:limit_plan_ids" json:"limit_plan_ids"`
|
||||
LimitPeriod *string `gorm:"column:limit_period" json:"limit_period"`
|
||||
LimitUse *int `gorm:"column:limit_use" json:"limit_use"`
|
||||
LimitUseWithUser *int `gorm:"column:limit_use_with_user" json:"limit_use_with_user"`
|
||||
StartedAt int64 `gorm:"column:started_at" json:"started_at"`
|
||||
EndedAt int64 `gorm:"column:ended_at" json:"ended_at"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Coupon) TableName() string {
|
||||
return "v2_coupon"
|
||||
}
|
||||
15
internal/model/invite_code.go
Normal file
15
internal/model/invite_code.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type InviteCode struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Status bool `gorm:"column:status" json:"status"`
|
||||
PV int `gorm:"column:pv" json:"pv"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (InviteCode) TableName() string {
|
||||
return "v2_invite_code"
|
||||
}
|
||||
17
internal/model/knowledge.go
Normal file
17
internal/model/knowledge.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
type Knowledge struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Language string `gorm:"column:language" json:"language"`
|
||||
Category string `gorm:"column:category" json:"category"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Body string `gorm:"column:body" json:"body"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Knowledge) TableName() string {
|
||||
return "v2_knowledge"
|
||||
}
|
||||
18
internal/model/notice.go
Normal file
18
internal/model/notice.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type Notice struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Content string `gorm:"column:content" json:"content"`
|
||||
ImgURL *string `gorm:"column:img_url" json:"img_url"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Popup bool `gorm:"column:popup" json:"popup"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Notice) TableName() string {
|
||||
return "v2_notice"
|
||||
}
|
||||
36
internal/model/order.go
Normal file
36
internal/model/order.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
type Order struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
PlanID *int `gorm:"column:plan_id" json:"plan_id"`
|
||||
PaymentID *int `gorm:"column:payment_id" json:"payment_id"`
|
||||
Period string `gorm:"column:period" json:"period"`
|
||||
TradeNo string `gorm:"column:trade_no" json:"trade_no"`
|
||||
TotalAmount int64 `gorm:"column:total_amount" json:"total_amount"`
|
||||
HandlingAmount *int64 `gorm:"column:handling_amount" json:"handling_amount"`
|
||||
BalanceAmount *int64 `gorm:"column:balance_amount" json:"balance_amount"`
|
||||
RefundAmount *int64 `gorm:"column:refund_amount" json:"refund_amount"`
|
||||
SurplusAmount *int64 `gorm:"column:surplus_amount" json:"surplus_amount"`
|
||||
Type int `gorm:"column:type" json:"type"`
|
||||
Status int `gorm:"column:status" json:"status"`
|
||||
SurplusOrderIDs *string `gorm:"column:surplus_order_ids" json:"surplus_order_ids"`
|
||||
CouponID *int `gorm:"column:coupon_id" json:"coupon_id"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
CommissionStatus *int `gorm:"column:commission_status" json:"commission_status"`
|
||||
InviteUserID *int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
ActualCommissionBalance *int64 `gorm:"column:actual_commission_balance" json:"actual_commission_balance"`
|
||||
CommissionRate *int `gorm:"column:commission_rate" json:"commission_rate"`
|
||||
CommissionAutoCheck *int `gorm:"column:commission_auto_check" json:"commission_auto_check"`
|
||||
CommissionBalance *int64 `gorm:"column:commission_balance" json:"commission_balance"`
|
||||
DiscountAmount *int64 `gorm:"column:discount_amount" json:"discount_amount"`
|
||||
PaidAt *int64 `gorm:"column:paid_at" json:"paid_at"`
|
||||
CallbackNo *string `gorm:"column:callback_no" json:"callback_no"`
|
||||
Plan *Plan `gorm:"foreignKey:PlanID" json:"plan,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
}
|
||||
|
||||
func (Order) TableName() string {
|
||||
return "v2_order"
|
||||
}
|
||||
19
internal/model/payment.go
Normal file
19
internal/model/payment.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
type Payment struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Payment string `gorm:"column:payment" json:"payment"`
|
||||
Icon *string `gorm:"column:icon" json:"icon"`
|
||||
Config *string `gorm:"column:config" json:"config"`
|
||||
HandlingFeeFixed *int64 `gorm:"column:handling_fee_fixed" json:"handling_fee_fixed"`
|
||||
HandlingFeePercent *int64 `gorm:"column:handling_fee_percent" json:"handling_fee_percent"`
|
||||
Enable bool `gorm:"column:enable" json:"enable"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Payment) TableName() string {
|
||||
return "v2_payment"
|
||||
}
|
||||
63
internal/model/plan_server.go
Normal file
63
internal/model/plan_server.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
GroupID *int `gorm:"column:group_id" json:"group_id"`
|
||||
TransferEnable *int `gorm:"column:transfer_enable" json:"transfer_enable"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Reference *string `gorm:"column:reference" json:"reference"`
|
||||
SpeedLimit *int `gorm:"column:speed_limit" json:"speed_limit"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
Renew bool `gorm:"column:renew;default:1" json:"renew"`
|
||||
Content *string `gorm:"column:content" json:"content"`
|
||||
ResetTrafficMethod *int `gorm:"column:reset_traffic_method;default:0" json:"reset_traffic_method"`
|
||||
CapacityLimit *int `gorm:"column:capacity_limit;default:0" json:"capacity_limit"`
|
||||
Prices *string `gorm:"column:prices" json:"prices"`
|
||||
Sell bool `gorm:"column:sell" json:"sell"`
|
||||
DeviceLimit *int `gorm:"column:device_limit" json:"device_limit"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Plan) TableName() string {
|
||||
return "v2_plan"
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
Code *string `gorm:"column:code" json:"code"`
|
||||
ParentID *int `gorm:"column:parent_id" json:"parent_id"`
|
||||
GroupIDs *string `gorm:"column:group_ids" json:"group_ids"`
|
||||
RouteIDs *string `gorm:"column:route_ids" json:"route_ids"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Rate float32 `gorm:"column:rate" json:"rate"`
|
||||
TransferEnable *int64 `gorm:"column:transfer_enable" json:"transfer_enable"`
|
||||
U int64 `gorm:"column:u;default:0" json:"u"`
|
||||
D int64 `gorm:"column:d;default:0" json:"d"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
Host string `gorm:"column:host" json:"host"`
|
||||
Port string `gorm:"column:port" json:"port"`
|
||||
ServerPort int `gorm:"column:server_port" json:"server_port"`
|
||||
ProtocolSettings *string `gorm:"column:protocol_settings" json:"protocol_settings"`
|
||||
CustomOutbounds *string `gorm:"column:custom_outbounds" json:"custom_outbounds"`
|
||||
CustomRoutes *string `gorm:"column:custom_routes" json:"custom_routes"`
|
||||
CertConfig *string `gorm:"column:cert_config" json:"cert_config"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
RateTimeEnable bool `gorm:"column:rate_time_enable" json:"rate_time_enable"`
|
||||
RateTimeRanges *string `gorm:"column:rate_time_ranges" json:"rate_time_ranges"`
|
||||
IPv6Password *string `gorm:"column:ipv6_password" json:"ipv6_password"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Server) TableName() string {
|
||||
return "v2_server"
|
||||
}
|
||||
21
internal/model/plugin.go
Normal file
21
internal/model/plugin.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
type Plugin struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Description *string `gorm:"column:description" json:"description"`
|
||||
Version *string `gorm:"column:version" json:"version"`
|
||||
Author *string `gorm:"column:author" json:"author"`
|
||||
URL *string `gorm:"column:url" json:"url"`
|
||||
Email *string `gorm:"column:email" json:"email"`
|
||||
License *string `gorm:"column:license" json:"license"`
|
||||
Requires *string `gorm:"column:requires" json:"requires"`
|
||||
Config *string `gorm:"column:config" json:"config"`
|
||||
Type *string `gorm:"column:type" json:"type"`
|
||||
IsEnabled bool `gorm:"column:is_enabled" json:"is_enabled"`
|
||||
}
|
||||
|
||||
func (Plugin) TableName() string {
|
||||
return "v2_plugins"
|
||||
}
|
||||
21
internal/model/realname.go
Normal file
21
internal/model/realname.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
type RealNameAuth struct {
|
||||
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID uint64 `gorm:"column:user_id;uniqueIndex" json:"user_id"`
|
||||
RealName string `gorm:"column:real_name" json:"real_name"`
|
||||
IdentityMasked string `gorm:"column:identity_masked" json:"identity_masked"`
|
||||
IdentityEncrypted string `gorm:"column:identity_encrypted" json:"-"`
|
||||
Status string `gorm:"column:status;index;default:pending" json:"status"`
|
||||
SubmittedAt int64 `gorm:"column:submitted_at" json:"submitted_at"`
|
||||
ReviewedAt int64 `gorm:"column:reviewed_at" json:"reviewed_at"`
|
||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
}
|
||||
|
||||
func (RealNameAuth) TableName() string {
|
||||
return "v2_realname_auth"
|
||||
}
|
||||
12
internal/model/server_group.go
Normal file
12
internal/model/server_group.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
type ServerGroup struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ServerGroup) TableName() string {
|
||||
return "v2_server_group"
|
||||
}
|
||||
15
internal/model/server_route.go
Normal file
15
internal/model/server_route.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type ServerRoute struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Remarks *string `gorm:"column:remarks" json:"remarks"`
|
||||
Match *string `gorm:"column:match" json:"match"`
|
||||
Action *string `gorm:"column:action" json:"action"`
|
||||
ActionValue *string `gorm:"column:action_value" json:"action_value"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ServerRoute) TableName() string {
|
||||
return "v2_server_route"
|
||||
}
|
||||
17
internal/model/setting.go
Normal file
17
internal/model/setting.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Setting struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Group *string `gorm:"column:group" json:"group"`
|
||||
Type *string `gorm:"column:type" json:"type"`
|
||||
Name string `gorm:"column:name;index" json:"name"`
|
||||
Value string `gorm:"column:value" json:"value"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Setting) TableName() string {
|
||||
return "v2_settings"
|
||||
}
|
||||
15
internal/model/stat_user.go
Normal file
15
internal/model/stat_user.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type StatUser struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
U int64 `gorm:"column:u" json:"u"`
|
||||
D int64 `gorm:"column:d" json:"d"`
|
||||
RecordAt int64 `gorm:"column:record_at" json:"record_at"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (StatUser) TableName() string {
|
||||
return "v2_stat_user"
|
||||
}
|
||||
16
internal/model/ticket.go
Normal file
16
internal/model/ticket.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
type Ticket struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Subject string `gorm:"column:subject" json:"subject"`
|
||||
Level int `gorm:"column:level" json:"level"`
|
||||
Status int `gorm:"column:status" json:"status"`
|
||||
ReplyStatus int `gorm:"column:reply_status" json:"reply_status"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
return "v2_ticket"
|
||||
}
|
||||
14
internal/model/ticket_message.go
Normal file
14
internal/model/ticket_message.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
type TicketMessage struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
TicketID int `gorm:"column:ticket_id" json:"ticket_id"`
|
||||
Message string `gorm:"column:message" json:"message"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TicketMessage) TableName() string {
|
||||
return "v2_ticket_message"
|
||||
}
|
||||
52
internal/model/user.go
Normal file
52
internal/model/user.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
ParentID *int `gorm:"column:parent_id" json:"parent_id"`
|
||||
InviteUserID *int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
TelegramID *int64 `gorm:"column:telegram_id" json:"telegram_id"`
|
||||
Email string `gorm:"column:email;unique" json:"email"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
PasswordAlgo *string `gorm:"column:password_algo" json:"-"`
|
||||
PasswordSalt *string `gorm:"column:password_salt" json:"-"`
|
||||
Balance uint64 `gorm:"column:balance;default:0" json:"balance"`
|
||||
Discount *int `gorm:"column:discount" json:"discount"`
|
||||
CommissionType int `gorm:"column:commission_type;default:0" json:"commission_type"`
|
||||
CommissionRate *int `gorm:"column:commission_rate" json:"commission_rate"`
|
||||
CommissionBalance uint64 `gorm:"column:commission_balance;default:0" json:"commission_balance"`
|
||||
T uint64 `gorm:"column:t;default:0" json:"t"`
|
||||
U uint64 `gorm:"column:u;default:0" json:"u"`
|
||||
D uint64 `gorm:"column:d;default:0" json:"d"`
|
||||
TransferEnable uint64 `gorm:"column:transfer_enable;default:0" json:"transfer_enable"`
|
||||
Banned bool `gorm:"column:banned" json:"banned"`
|
||||
IsAdmin bool `gorm:"column:is_admin" json:"is_admin"`
|
||||
IsStaff bool `gorm:"column:is_staff" json:"is_staff"`
|
||||
LastLoginAt *int64 `gorm:"column:last_login_at" json:"last_login_at"`
|
||||
LastLoginIP *int64 `gorm:"column:last_login_ip" json:"last_login_ip"`
|
||||
UUID string `gorm:"column:uuid" json:"uuid"`
|
||||
GroupID *int `gorm:"column:group_id" json:"group_id"`
|
||||
PlanID *int `gorm:"column:plan_id" json:"plan_id"`
|
||||
Plan *Plan `gorm:"foreignKey:PlanID" json:"plan"`
|
||||
SpeedLimit *int `gorm:"column:speed_limit" json:"speed_limit"`
|
||||
RemindExpire int `gorm:"column:remind_expire;default:1" json:"remind_expire"`
|
||||
RemindTraffic int `gorm:"column:remind_traffic;default:1" json:"remind_traffic"`
|
||||
Token string `gorm:"column:token" json:"token"`
|
||||
ExpiredAt *int64 `gorm:"column:expired_at" json:"expired_at"`
|
||||
Remarks *string `gorm:"column:remarks" json:"remarks"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
DeviceLimit *int `gorm:"column:device_limit" json:"device_limit"`
|
||||
OnlineCount *int `gorm:"column:online_count" json:"online_count"`
|
||||
LastOnlineAt *time.Time `gorm:"column:last_online_at" json:"last_online_at"`
|
||||
NextResetAt *int64 `gorm:"column:next_reset_at" json:"next_reset_at"`
|
||||
LastResetAt *int64 `gorm:"column:last_reset_at" json:"last_reset_at"`
|
||||
ResetCount int `gorm:"column:reset_count;default:0" json:"reset_count"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "v2_user"
|
||||
}
|
||||
32
internal/protocol/clash.go
Normal file
32
internal/protocol/clash.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
func GenerateClash(servers []model.Server, user model.User) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("proxies:\n")
|
||||
var proxyNames []string
|
||||
for _, s := range servers {
|
||||
// Basic VMess conversion for Clash
|
||||
proxy := fmt.Sprintf(" - name: \"%s\"\n type: vmess\n server: %s\n port: %s\n uuid: %s\n alterId: 0\n cipher: auto\n",
|
||||
s.Name, s.Host, s.Port, user.UUID)
|
||||
builder.WriteString(proxy)
|
||||
proxyNames = append(proxyNames, fmt.Sprintf("\"%s\"", s.Name))
|
||||
}
|
||||
|
||||
builder.WriteString("\nproxy-groups:\n")
|
||||
builder.WriteString(" - name: Proxy\n type: select\n proxies:\n - DIRECT\n")
|
||||
for _, name := range proxyNames {
|
||||
builder.WriteString(" - " + name + "\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\nrules:\n")
|
||||
builder.WriteString(" - MATCH,Proxy\n")
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
42
internal/protocol/singbox.go
Normal file
42
internal/protocol/singbox.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
type SingBoxConfig struct {
|
||||
Outbounds []map[string]interface{} `json:"outbounds"`
|
||||
}
|
||||
|
||||
func GenerateSingBox(servers []model.Server, user model.User) (string, error) {
|
||||
config := SingBoxConfig{
|
||||
Outbounds: []map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Add selector outbound
|
||||
selector := map[string]interface{}{
|
||||
"type": "selector",
|
||||
"tag": "Proxy",
|
||||
"outbounds": []string{},
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
outbound := map[string]interface{}{
|
||||
"type": s.Type,
|
||||
"tag": s.Name,
|
||||
"server": s.Host,
|
||||
"server_port": s.Port,
|
||||
}
|
||||
// Add protocol-specific settings
|
||||
// ... logic to handle VMess, Shadowsocks, etc.
|
||||
|
||||
config.Outbounds = append(config.Outbounds, outbound)
|
||||
selector["outbounds"] = append(selector["outbounds"].([]string), s.Name)
|
||||
}
|
||||
|
||||
config.Outbounds = append(config.Outbounds, selector)
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
return string(data), err
|
||||
}
|
||||
97
internal/service/device_state.go
Normal file
97
internal/service/device_state.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
)
|
||||
|
||||
const deviceStateTTL = 10 * time.Minute
|
||||
|
||||
type userDevicesSnapshot map[string][]string
|
||||
|
||||
func SaveUserNodeDevices(userID, nodeID int, ips []string) error {
|
||||
unique := make([]string, 0, len(ips))
|
||||
seen := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
trimmed := strings.TrimSpace(ip)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
unique = append(unique, trimmed)
|
||||
}
|
||||
sort.Strings(unique)
|
||||
|
||||
return database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL)
|
||||
}
|
||||
|
||||
func GetUsersDevices(userIDs []int) map[int][]string {
|
||||
result := make(map[int][]string, len(userIDs))
|
||||
|
||||
for _, userID := range userIDs {
|
||||
snapshot, ok := database.CacheGetJSON[userDevicesSnapshot](deviceStateUserIndexKey(userID))
|
||||
if !ok {
|
||||
result[userID] = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
merged := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, ips := range snapshot {
|
||||
for _, ip := range ips {
|
||||
if _, exists := seen[ip]; exists {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
merged = append(merged, ip)
|
||||
}
|
||||
}
|
||||
sort.Strings(merged)
|
||||
result[userID] = merged
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func SetDevices(userID, nodeID int, ips []string) error {
|
||||
if err := SaveUserNodeDevices(userID, nodeID, ips); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := deviceStateUserIndexKey(userID)
|
||||
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
|
||||
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
|
||||
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
|
||||
}
|
||||
|
||||
func normalizeIPs(ips []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
result = append(result, ip)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func deviceStateKey(userID, nodeID int) string {
|
||||
return fmt.Sprintf("device_state:user:%d:node:%d", userID, nodeID)
|
||||
}
|
||||
|
||||
func deviceStateUserIndexKey(userID int) string {
|
||||
return fmt.Sprintf("device_state:user:%d:index", userID)
|
||||
}
|
||||
437
internal/service/node.go
Normal file
437
internal/service/node.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var serverTypeAliases = map[string]string{
|
||||
"v2ray": "vmess",
|
||||
"hysteria2": "hysteria",
|
||||
}
|
||||
|
||||
var validServerTypes = map[string]struct{}{
|
||||
"anytls": {},
|
||||
"http": {},
|
||||
"hysteria": {},
|
||||
"mieru": {},
|
||||
"naive": {},
|
||||
"shadowsocks": {},
|
||||
"socks": {},
|
||||
"trojan": {},
|
||||
"tuic": {},
|
||||
"vless": {},
|
||||
"vmess": {},
|
||||
}
|
||||
|
||||
type NodeUser struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
SpeedLimit *int `json:"speed_limit,omitempty"`
|
||||
DeviceLimit *int `json:"device_limit,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeServerType(serverType string) string {
|
||||
serverType = strings.ToLower(strings.TrimSpace(serverType))
|
||||
if serverType == "" {
|
||||
return ""
|
||||
}
|
||||
if alias, ok := serverTypeAliases[serverType]; ok {
|
||||
return alias
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
func IsValidServerType(serverType string) bool {
|
||||
if serverType == "" {
|
||||
return true
|
||||
}
|
||||
_, ok := validServerTypes[NormalizeServerType(serverType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FindServer(nodeID, nodeType string) (*model.Server, error) {
|
||||
query := database.DB.Model(&model.Server{})
|
||||
if normalized := NormalizeServerType(nodeType); normalized != "" {
|
||||
query = query.Where("type = ?", normalized)
|
||||
}
|
||||
|
||||
var server model.Server
|
||||
if err := query.
|
||||
Where("code = ? OR id = ?", nodeID, nodeID).
|
||||
Order(gorm.Expr("CASE WHEN code = ? THEN 0 ELSE 1 END", nodeID)).
|
||||
First(&server).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.Type = NormalizeServerType(server.Type)
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
func AvailableUsersForNode(node *model.Server) ([]NodeUser, error) {
|
||||
groupIDs := parseIntSlice(node.GroupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
return []NodeUser{}, nil
|
||||
}
|
||||
|
||||
var users []NodeUser
|
||||
err := database.DB.Model(&model.User{}).
|
||||
Select("id", "uuid", "speed_limit", "device_limit").
|
||||
Where("group_id IN ?", groupIDs).
|
||||
Where("u + d < transfer_enable").
|
||||
Where("(expired_at >= ? OR expired_at IS NULL)", time.Now().Unix()).
|
||||
Where("banned = ?", 0).
|
||||
Scan(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func AvailableServersForUser(user *model.User) ([]model.Server, error) {
|
||||
var servers []model.Server
|
||||
if err := database.DB.Where("`show` = ?", 1).Order("sort ASC").Find(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]model.Server, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
groupIDs := parseIntSlice(server.GroupIDs)
|
||||
if user.GroupID != nil && len(groupIDs) > 0 && !containsInt(groupIDs, *user.GroupID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if server.TransferEnable != nil && *server.TransferEnable > 0 && server.U+server.D >= *server.TransferEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func CurrentRate(server *model.Server) float64 {
|
||||
if !server.RateTimeEnable {
|
||||
return float64(server.Rate)
|
||||
}
|
||||
|
||||
ranges := parseObjectSlice(server.RateTimeRanges)
|
||||
now := time.Now().Format("15:04")
|
||||
for _, item := range ranges {
|
||||
start, _ := item["start"].(string)
|
||||
end, _ := item["end"].(string)
|
||||
if start != "" && end != "" && now >= start && now <= end {
|
||||
if rate, ok := toFloat64(item["rate"]); ok {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return float64(server.Rate)
|
||||
}
|
||||
|
||||
func BuildNodeConfig(node *model.Server) map[string]any {
|
||||
settings := parseObject(node.ProtocolSettings)
|
||||
response := map[string]any{
|
||||
"protocol": node.Type,
|
||||
"listen_ip": "0.0.0.0",
|
||||
"server_port": node.ServerPort,
|
||||
"network": getMapString(settings, "network"),
|
||||
"networkSettings": getMapAny(settings, "network_settings"),
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case "shadowsocks":
|
||||
response["cipher"] = getMapString(settings, "cipher")
|
||||
response["plugin"] = getMapString(settings, "plugin")
|
||||
response["plugin_opts"] = getMapString(settings, "plugin_opts")
|
||||
cipher := getMapString(settings, "cipher")
|
||||
switch cipher {
|
||||
case "2022-blake3-aes-128-gcm":
|
||||
response["server_key"] = serverKey(node.CreatedAt, 16)
|
||||
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
|
||||
response["server_key"] = serverKey(node.CreatedAt, 32)
|
||||
}
|
||||
case "vmess":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
case "trojan":
|
||||
response["host"] = node.Host
|
||||
response["server_name"] = getMapString(settings, "server_name")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
if getMapInt(settings, "tls") == 2 {
|
||||
response["tls_settings"] = getMapAny(settings, "reality_settings")
|
||||
}
|
||||
case "vless":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["flow"] = getMapString(settings, "flow")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
if encryption, ok := settings["encryption"].(map[string]any); ok {
|
||||
if enabled, ok := encryption["enabled"].(bool); ok && enabled {
|
||||
response["decryption"] = stringify(encryption["decryption"])
|
||||
}
|
||||
}
|
||||
if getMapInt(settings, "tls") == 2 {
|
||||
response["tls_settings"] = getMapAny(settings, "reality_settings")
|
||||
} else {
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
}
|
||||
case "hysteria":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
obfs, _ := settings["obfs"].(map[string]any)
|
||||
bandwidth, _ := settings["bandwidth"].(map[string]any)
|
||||
version := getMapInt(settings, "version")
|
||||
response["version"] = version
|
||||
response["host"] = node.Host
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["up_mbps"] = mapAnyInt(bandwidth, "up")
|
||||
response["down_mbps"] = mapAnyInt(bandwidth, "down")
|
||||
if version == 1 {
|
||||
response["obfs"] = stringify(obfs["password"])
|
||||
} else if version == 2 {
|
||||
if open, ok := obfs["open"].(bool); ok && open {
|
||||
response["obfs"] = stringify(obfs["type"])
|
||||
response["obfs-password"] = stringify(obfs["password"])
|
||||
}
|
||||
}
|
||||
case "tuic":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
response["version"] = getMapInt(settings, "version")
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["congestion_control"] = getMapString(settings, "congestion_control")
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
response["auth_timeout"] = "3s"
|
||||
response["zero_rtt_handshake"] = false
|
||||
response["heartbeat"] = "3s"
|
||||
case "anytls":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["padding_scheme"] = getMapAny(settings, "padding_scheme")
|
||||
case "naive", "http":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
case "mieru":
|
||||
response["transport"] = getMapString(settings, "transport")
|
||||
response["traffic_pattern"] = getMapString(settings, "traffic_pattern")
|
||||
}
|
||||
|
||||
if routeIDs := parseIntSlice(node.RouteIDs); len(routeIDs) > 0 {
|
||||
var routes []model.ServerRoute
|
||||
if err := database.DB.Select("id", "`match`", "action", "action_value").Where("id IN ?", routeIDs).Find(&routes).Error; err == nil {
|
||||
response["routes"] = routes
|
||||
}
|
||||
}
|
||||
if value := parseGenericJSON(node.CustomOutbounds); value != nil {
|
||||
response["custom_outbounds"] = value
|
||||
}
|
||||
if value := parseGenericJSON(node.CustomRoutes); value != nil {
|
||||
response["custom_routes"] = value
|
||||
}
|
||||
if value := parseGenericJSON(node.CertConfig); value != nil {
|
||||
response["cert_config"] = value
|
||||
}
|
||||
|
||||
return pruneNilMap(response)
|
||||
}
|
||||
|
||||
func ApplyTrafficDelta(userID int, node *model.Server, upload, download int64) {
|
||||
rate := CurrentRate(node)
|
||||
scaledUpload := int64(math.Round(float64(upload) * rate))
|
||||
scaledDownload := int64(math.Round(float64(download) * rate))
|
||||
|
||||
database.DB.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"u": gorm.Expr("u + ?", scaledUpload),
|
||||
"d": gorm.Expr("d + ?", scaledDownload),
|
||||
"t": time.Now().Unix(),
|
||||
})
|
||||
|
||||
database.DB.Model(&model.Server{}).
|
||||
Where("id = ?", node.ID).
|
||||
Updates(map[string]any{
|
||||
"u": gorm.Expr("u + ?", scaledUpload),
|
||||
"d": gorm.Expr("d + ?", scaledDownload),
|
||||
})
|
||||
}
|
||||
|
||||
func serverKey(createdAt *time.Time, size int) string {
|
||||
if createdAt == nil {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum([]byte(strconv.FormatInt(createdAt.Unix(), 10)))
|
||||
hex := fmt.Sprintf("%x", sum)
|
||||
if size > len(hex) {
|
||||
size = len(hex)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(hex[:size]))
|
||||
}
|
||||
|
||||
func parseIntSlice(raw *string) []int {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var decoded []any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err == nil {
|
||||
result := make([]int, 0, len(decoded))
|
||||
for _, item := range decoded {
|
||||
if value, ok := toInt(item); ok {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
parts := strings.Split(*raw, ",")
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseObject(raw *string) map[string]any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func parseObjectSlice(raw *string) []map[string]any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var decoded []map[string]any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func parseGenericJSON(raw *string) any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func getMapString(values map[string]any, key string) string {
|
||||
return stringify(values[key])
|
||||
}
|
||||
|
||||
func getMapInt(values map[string]any, key string) int {
|
||||
if value, ok := toInt(values[key]); ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getMapAny(values map[string]any, key string) any {
|
||||
if value, ok := values[key]; ok {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapAnyInt(values map[string]any, key string) int {
|
||||
if value, ok := toInt(values[key]); ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func stringify(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func pruneNilMap(values map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok && text == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
return int(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
158
internal/service/plugin.go
Normal file
158
internal/service/plugin.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginRealNameVerification = "real_name_verification"
|
||||
PluginUserOnlineDevices = "user_online_devices"
|
||||
PluginUserAddIPv6 = "user_add_ipv6_subscription"
|
||||
)
|
||||
|
||||
func GetPlugin(code string) (*model.Plugin, bool) {
|
||||
var plugin model.Plugin
|
||||
if err := database.DB.Where("code = ?", code).First(&plugin).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &plugin, true
|
||||
}
|
||||
|
||||
func IsPluginEnabled(code string) bool {
|
||||
plugin, ok := GetPlugin(code)
|
||||
return ok && plugin.IsEnabled
|
||||
}
|
||||
|
||||
func GetPluginConfig(code string) map[string]any {
|
||||
plugin, ok := GetPlugin(code)
|
||||
if !ok || plugin.Config == nil || *plugin.Config == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(*plugin.Config), &cfg); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func GetPluginConfigString(code, key, defaultValue string) string {
|
||||
cfg := GetPluginConfig(code)
|
||||
value, ok := cfg[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
if raw, ok := value.(string); ok && raw != "" {
|
||||
return raw
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func GetPluginConfigBool(code, key string, defaultValue bool) bool {
|
||||
cfg := GetPluginConfig(code)
|
||||
value, ok := cfg[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed != 0
|
||||
case string:
|
||||
return typed == "1" || typed == "true"
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func SyncIPv6ShadowAccount(user *model.User) bool {
|
||||
if user == nil || user.PlanID == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
if !PluginPlanAllowed(&plan) {
|
||||
return false
|
||||
}
|
||||
|
||||
ipv6Email := IPv6ShadowEmail(user.Email)
|
||||
var ipv6User model.User
|
||||
now := time.Now().Unix()
|
||||
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
|
||||
ipv6User = *user
|
||||
ipv6User.ID = 0
|
||||
ipv6User.Email = ipv6Email
|
||||
ipv6User.UUID = uuid.New().String()
|
||||
ipv6User.Token = fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+ipv6Email)))[:16]
|
||||
ipv6User.U = 0
|
||||
ipv6User.D = 0
|
||||
ipv6User.T = 0
|
||||
ipv6User.ParentID = &user.ID
|
||||
ipv6User.CreatedAt = now
|
||||
}
|
||||
|
||||
ipv6User.Email = ipv6Email
|
||||
ipv6User.Password = user.Password
|
||||
ipv6User.U = 0
|
||||
ipv6User.D = 0
|
||||
ipv6User.T = 0
|
||||
ipv6User.UpdatedAt = now
|
||||
|
||||
if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 {
|
||||
ipv6User.PlanID = &planID
|
||||
}
|
||||
if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 {
|
||||
ipv6User.GroupID = &groupID
|
||||
}
|
||||
|
||||
return database.DB.Save(&ipv6User).Error == nil
|
||||
}
|
||||
|
||||
func PluginPlanAllowed(plan *model.Plan) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(GetPluginConfigString(PluginUserAddIPv6, "allowed_plans", ""), ",") {
|
||||
if parsePluginPositiveInt(strings.TrimSpace(raw), 0) == plan.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
referenceFlag := strings.ToLower(GetPluginConfigString(PluginUserAddIPv6, "reference_flag", "ipv6"))
|
||||
reference := ""
|
||||
if plan.Reference != nil {
|
||||
reference = strings.ToLower(*plan.Reference)
|
||||
}
|
||||
return referenceFlag != "" && strings.Contains(reference, referenceFlag)
|
||||
}
|
||||
|
||||
func IPv6ShadowEmail(email string) string {
|
||||
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return email
|
||||
}
|
||||
return parts[0] + suffix + "@" + parts[1]
|
||||
}
|
||||
|
||||
func parsePluginPositiveInt(raw string, defaultValue int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
208
internal/service/session.go
Normal file
208
internal/service/session.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
type SessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
Name string `json:"name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
|
||||
now := time.Now().Unix()
|
||||
tokenHash := hashToken(token)
|
||||
list := activeSessions(userID, now)
|
||||
|
||||
for i := range list {
|
||||
if list[i].TokenHash == tokenHash {
|
||||
list[i].IP = firstNonEmpty(ip, list[i].IP)
|
||||
list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent)
|
||||
list[i].LastUsedAt = now
|
||||
saveSessions(userID, list)
|
||||
return list[i]
|
||||
}
|
||||
}
|
||||
|
||||
record := SessionRecord{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
Name: "Web Session",
|
||||
UserAgent: userAgent,
|
||||
IP: ip,
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now + int64(sessionTTL.Seconds()),
|
||||
}
|
||||
list = append(list, record)
|
||||
saveSessions(userID, list)
|
||||
return record
|
||||
}
|
||||
|
||||
func GetUserSessions(userID int, currentToken string) []SessionRecord {
|
||||
now := time.Now().Unix()
|
||||
list := activeSessions(userID, now)
|
||||
if currentToken != "" {
|
||||
currentHash := hashToken(currentToken)
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].TokenHash == currentHash {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
list = append(list, SessionRecord{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: currentHash,
|
||||
Name: "Web Session",
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now + int64(sessionTTL.Seconds()),
|
||||
})
|
||||
saveSessions(userID, list)
|
||||
}
|
||||
}
|
||||
return activeSessions(userID, now)
|
||||
}
|
||||
|
||||
func RemoveUserSession(userID int, sessionID string) bool {
|
||||
now := time.Now().Unix()
|
||||
list := activeSessions(userID, now)
|
||||
next := make([]SessionRecord, 0, len(list))
|
||||
removed := false
|
||||
|
||||
for _, session := range list {
|
||||
if session.ID != sessionID {
|
||||
next = append(next, session)
|
||||
continue
|
||||
}
|
||||
|
||||
removed = true
|
||||
ttl := time.Until(time.Unix(session.ExpiresAt, 0))
|
||||
if ttl < time.Hour {
|
||||
ttl = time.Hour
|
||||
}
|
||||
_ = database.CacheSet(revokedTokenKey(session.TokenHash), true, ttl)
|
||||
}
|
||||
|
||||
if removed {
|
||||
saveSessions(userID, next)
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
func IsSessionTokenRevoked(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := database.CacheGetJSON[bool](revokedTokenKey(hashToken(token)))
|
||||
return ok
|
||||
}
|
||||
|
||||
func StoreQuickLoginToken(userID int, ttl time.Duration) string {
|
||||
verify := uuid.NewString()
|
||||
_ = database.CacheSet(quickLoginKey(verify), userID, ttl)
|
||||
return verify
|
||||
}
|
||||
|
||||
func ResolveQuickLoginToken(verify string) (int, bool) {
|
||||
userID, ok := database.CacheGetJSON[int](quickLoginKey(verify))
|
||||
if ok {
|
||||
_ = database.CacheDelete(quickLoginKey(verify))
|
||||
}
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func StoreEmailVerifyCode(email, code string, ttl time.Duration) {
|
||||
_ = database.CacheSet(emailVerifyKey(email), code, ttl)
|
||||
}
|
||||
|
||||
func CheckEmailVerifyCode(email, code string) bool {
|
||||
savedCode, ok := database.CacheGetJSON[string](emailVerifyKey(email))
|
||||
if !ok || savedCode == "" || savedCode != code {
|
||||
return false
|
||||
}
|
||||
_ = database.CacheDelete(emailVerifyKey(email))
|
||||
return true
|
||||
}
|
||||
|
||||
func activeSessions(userID int, now int64) []SessionRecord {
|
||||
sessions, ok := database.CacheGetJSON[[]SessionRecord](userSessionsKey(userID))
|
||||
if !ok || len(sessions) == 0 {
|
||||
return []SessionRecord{}
|
||||
}
|
||||
|
||||
filtered := make([]SessionRecord, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
if session.ExpiresAt > 0 && session.ExpiresAt <= now {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, session)
|
||||
}
|
||||
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].LastUsedAt > filtered[j].LastUsedAt
|
||||
})
|
||||
|
||||
if len(filtered) != len(sessions) {
|
||||
saveSessions(userID, filtered)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func saveSessions(userID int, sessions []SessionRecord) {
|
||||
sort.SliceStable(sessions, func(i, j int) bool {
|
||||
return sessions[i].LastUsedAt > sessions[j].LastUsedAt
|
||||
})
|
||||
_ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func userSessionsKey(userID int) string {
|
||||
return "session:user:" + strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
func revokedTokenKey(tokenHash string) string {
|
||||
return "session:revoked:" + tokenHash
|
||||
}
|
||||
|
||||
func quickLoginKey(verify string) string {
|
||||
return "session:quick-login:" + verify
|
||||
}
|
||||
|
||||
func emailVerifyKey(email string) string {
|
||||
return "session:email-code:" + email
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
72
internal/service/settings.go
Normal file
72
internal/service/settings.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"xboard-go/internal/config"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
func GetSetting(name string) (string, bool) {
|
||||
var setting model.Setting
|
||||
if err := database.DB.Select("value").Where("name = ?", name).First(&setting).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return setting.Value, true
|
||||
}
|
||||
|
||||
func MustGetString(name, defaultValue string) string {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func MustGetInt(name string, defaultValue int) int {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func MustGetBool(name string, defaultValue bool) bool {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func GetAdminSecurePath() string {
|
||||
if securePath := strings.Trim(MustGetString("secure_path", ""), "/"); securePath != "" {
|
||||
return securePath
|
||||
}
|
||||
if frontendPath := strings.Trim(MustGetString("frontend_admin_path", ""), "/"); frontendPath != "" {
|
||||
return frontendPath
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func GetAppURL() string {
|
||||
if appURL := strings.TrimSpace(MustGetString("app_url", "")); appURL != "" {
|
||||
return strings.TrimRight(appURL, "/")
|
||||
}
|
||||
return strings.TrimRight(config.AppConfig.AppURL, "/")
|
||||
}
|
||||
82
pkg/utils/auth.go
Normal file
82
pkg/utils/auth.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
"xboard-go/internal/config"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int `json:"user_id"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(userID int, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * 7 * time.Hour)), // 7 days
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(config.AppConfig.JWTSecret))
|
||||
}
|
||||
|
||||
func VerifyToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(config.AppConfig.JWTSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, jwt.ErrSignatureInvalid
|
||||
}
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
func CheckPassword(password, hash string, algo, salt *string) bool {
|
||||
if algo != nil {
|
||||
switch *algo {
|
||||
case "md5":
|
||||
sum := md5.Sum([]byte(password))
|
||||
return hex.EncodeToString(sum[:]) == hash
|
||||
case "sha256":
|
||||
sum := sha256.Sum256([]byte(password))
|
||||
return hex.EncodeToString(sum[:]) == hash
|
||||
case "md5salt":
|
||||
sum := md5.Sum([]byte(password + stringValue(salt)))
|
||||
return hex.EncodeToString(sum[:]) == hash
|
||||
case "sha256salt":
|
||||
sum := sha256.Sum256([]byte(password + stringValue(salt)))
|
||||
return hex.EncodeToString(sum[:]) == hash
|
||||
}
|
||||
}
|
||||
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
178
scripts/build_install.sh
Normal file
178
scripts/build_install.sh
Normal file
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
GO_MOD_FILE="${ROOT_DIR}/go.mod"
|
||||
DEFAULT_GO_VERSION="$(awk '/^go / { print $2; exit }' "${GO_MOD_FILE}")"
|
||||
|
||||
GO_VERSION="${DEFAULT_GO_VERSION}"
|
||||
OUTPUT_PATH="${ROOT_DIR}/api"
|
||||
TOOLS_DIR="${ROOT_DIR}/.build-tools"
|
||||
CACHE_DIR="${ROOT_DIR}/.build-cache"
|
||||
CLEAN_GO="false"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: scripts/build_install.sh [options]
|
||||
|
||||
Options:
|
||||
--go-version <version> Go version to download, default: ${GO_VERSION}
|
||||
--output <path> Build output path, default: ${OUTPUT_PATH}
|
||||
--tools-dir <dir> Toolchain directory, default: ${TOOLS_DIR}
|
||||
--cache-dir <dir> Go cache directory, default: ${CACHE_DIR}
|
||||
--clean-go Re-download the Go toolchain even if it exists
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
bash scripts/build_install.sh
|
||||
bash scripts/build_install.sh --output ./package/api
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--go-version)
|
||||
GO_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--output)
|
||||
OUTPUT_PATH="$2"
|
||||
shift 2
|
||||
;;
|
||||
--tools-dir)
|
||||
TOOLS_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--cache-dir)
|
||||
CACHE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--clean-go)
|
||||
CLEAN_GO="true"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "error: required command not found: $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
detect_platform() {
|
||||
local uname_os uname_arch
|
||||
uname_os="$(uname -s)"
|
||||
uname_arch="$(uname -m)"
|
||||
|
||||
case "${uname_os}" in
|
||||
Linux) GO_OS="linux" ;;
|
||||
Darwin) GO_OS="darwin" ;;
|
||||
*)
|
||||
echo "error: unsupported operating system: ${uname_os}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${uname_arch}" in
|
||||
x86_64|amd64) GO_ARCH="amd64" ;;
|
||||
aarch64|arm64) GO_ARCH="arm64" ;;
|
||||
*)
|
||||
echo "error: unsupported architecture: ${uname_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
download_go() {
|
||||
local archive_name archive_path download_url current_version
|
||||
|
||||
mkdir -p "${TOOLS_DIR}"
|
||||
archive_name="go${GO_VERSION}.${GO_OS}-${GO_ARCH}.tar.gz"
|
||||
archive_path="${TOOLS_DIR}/${archive_name}"
|
||||
GO_ROOT="${TOOLS_DIR}/go"
|
||||
|
||||
if [[ -x "${GO_ROOT}/bin/go" ]]; then
|
||||
current_version="$("${GO_ROOT}/bin/go" version | awk '{print $3}' | sed 's/^go//')"
|
||||
if [[ "${current_version}" != "${GO_VERSION}" ]]; then
|
||||
rm -rf "${GO_ROOT}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${CLEAN_GO}" == "true" ]]; then
|
||||
rm -rf "${GO_ROOT}"
|
||||
rm -f "${archive_path}"
|
||||
fi
|
||||
|
||||
if [[ ! -x "${GO_ROOT}/bin/go" ]]; then
|
||||
download_url="https://go.dev/dl/${archive_name}"
|
||||
echo "downloading Go ${GO_VERSION} for ${GO_OS}/${GO_ARCH}..."
|
||||
if [[ ! -f "${archive_path}" ]]; then
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL "${download_url}" -o "${archive_path}"
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
wget -O "${archive_path}" "${download_url}"
|
||||
else
|
||||
echo "error: curl or wget is required to download Go" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -rf "${GO_ROOT}"
|
||||
tar -C "${TOOLS_DIR}" -xzf "${archive_path}"
|
||||
fi
|
||||
}
|
||||
|
||||
build_binary() {
|
||||
local output_dir
|
||||
|
||||
mkdir -p "${CACHE_DIR}/gocache" "${CACHE_DIR}/gomodcache"
|
||||
output_dir="$(dirname "${OUTPUT_PATH}")"
|
||||
mkdir -p "${output_dir}"
|
||||
|
||||
echo "building ${OUTPUT_PATH}..."
|
||||
(
|
||||
cd "${ROOT_DIR}"
|
||||
export GOROOT="${GO_ROOT}"
|
||||
export PATH="${GO_ROOT}/bin:${PATH}"
|
||||
export GOCACHE="${CACHE_DIR}/gocache"
|
||||
export GOMODCACHE="${CACHE_DIR}/gomodcache"
|
||||
"${GO_ROOT}/bin/go" build -trimpath -o "${OUTPUT_PATH}" ./cmd/api
|
||||
)
|
||||
chmod +x "${OUTPUT_PATH}" || true
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
cat <<EOF
|
||||
|
||||
Build complete.
|
||||
|
||||
Go version: ${GO_VERSION}
|
||||
Toolchain: ${GO_ROOT}
|
||||
Output: ${OUTPUT_PATH}
|
||||
Cache dir: ${CACHE_DIR}
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
require_cmd tar
|
||||
detect_platform
|
||||
download_go
|
||||
build_binary
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
210
scripts/install.sh
Normal file
210
scripts/install.sh
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_NAME="singbox-gopanel"
|
||||
SERVICE_NAME="singbox-gopanel"
|
||||
INSTALL_DIR="/opt/${APP_NAME}"
|
||||
RUN_USER="${SUDO_USER:-${USER:-root}}"
|
||||
SKIP_SERVICE="false"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PACKAGE_DIR="${SCRIPT_DIR}"
|
||||
BIN_NAME="api"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: install.sh [options]
|
||||
|
||||
Options:
|
||||
--install-dir <dir> Install directory, default: ${INSTALL_DIR}
|
||||
--service-name <name> systemd service name, default: ${SERVICE_NAME}
|
||||
--run-user <user> Runtime user, default: ${RUN_USER}
|
||||
--package-dir <dir> Package directory, default: ${PACKAGE_DIR}
|
||||
--skip-service Skip systemd service installation
|
||||
-h, --help Show this help
|
||||
|
||||
Expected package layout:
|
||||
${BIN_NAME}
|
||||
frontend/
|
||||
docs/
|
||||
README.md
|
||||
.env.example (optional)
|
||||
|
||||
Example:
|
||||
sudo bash ./install.sh --install-dir /opt/singbox-gopanel --run-user root
|
||||
EOF
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--install-dir)
|
||||
INSTALL_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--service-name)
|
||||
SERVICE_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--run-user)
|
||||
RUN_USER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--package-dir)
|
||||
PACKAGE_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-service)
|
||||
SKIP_SERVICE="true"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "error: unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
ensure_root() {
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
echo "error: please run as root or via sudo" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_run_user() {
|
||||
if ! id "${RUN_USER}" >/dev/null 2>&1; then
|
||||
echo "error: run user does not exist: ${RUN_USER}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_package_layout() {
|
||||
if [[ ! -f "${PACKAGE_DIR}/${BIN_NAME}" ]]; then
|
||||
echo "error: binary not found: ${PACKAGE_DIR}/${BIN_NAME}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "${PACKAGE_DIR}/frontend" ]]; then
|
||||
echo "error: frontend directory not found: ${PACKAGE_DIR}/frontend" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "${PACKAGE_DIR}/docs" ]]; then
|
||||
echo "error: docs directory not found: ${PACKAGE_DIR}/docs" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_dirs() {
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
mkdir -p "${INSTALL_DIR}/frontend"
|
||||
mkdir -p "${INSTALL_DIR}/docs"
|
||||
}
|
||||
|
||||
copy_runtime_files() {
|
||||
echo "copying package files into ${INSTALL_DIR}..."
|
||||
|
||||
install -m 0755 "${PACKAGE_DIR}/${BIN_NAME}" "${INSTALL_DIR}/${BIN_NAME}"
|
||||
|
||||
rm -rf "${INSTALL_DIR}/frontend"
|
||||
mkdir -p "${INSTALL_DIR}/frontend"
|
||||
cp -R "${PACKAGE_DIR}/frontend/." "${INSTALL_DIR}/frontend/"
|
||||
|
||||
rm -rf "${INSTALL_DIR}/docs"
|
||||
mkdir -p "${INSTALL_DIR}/docs"
|
||||
cp -R "${PACKAGE_DIR}/docs/." "${INSTALL_DIR}/docs/"
|
||||
|
||||
if [[ -f "${PACKAGE_DIR}/README.md" ]]; then
|
||||
cp -f "${PACKAGE_DIR}/README.md" "${INSTALL_DIR}/README.md"
|
||||
fi
|
||||
|
||||
if [[ -f "${PACKAGE_DIR}/.env.example" ]]; then
|
||||
cp -f "${PACKAGE_DIR}/.env.example" "${INSTALL_DIR}/.env.example"
|
||||
fi
|
||||
|
||||
if [[ -f "${PACKAGE_DIR}/.env" && ! -f "${INSTALL_DIR}/.env" ]]; then
|
||||
cp -f "${PACKAGE_DIR}/.env" "${INSTALL_DIR}/.env"
|
||||
fi
|
||||
|
||||
if [[ ! -f "${INSTALL_DIR}/.env" && -f "${INSTALL_DIR}/.env.example" ]]; then
|
||||
cp -f "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
echo "created ${INSTALL_DIR}/.env from .env.example"
|
||||
fi
|
||||
}
|
||||
|
||||
install_service() {
|
||||
if [[ "${SKIP_SERVICE}" == "true" ]]; then
|
||||
echo "skip service enabled"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "systemctl not found, skipping service installation"
|
||||
return
|
||||
fi
|
||||
|
||||
cat >"/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=SingBox GoPanel API
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${RUN_USER}
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=-${INSTALL_DIR}/.env
|
||||
ExecStart=${INSTALL_DIR}/${BIN_NAME}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${SERVICE_NAME}"
|
||||
systemctl restart "${SERVICE_NAME}"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
cat <<EOF
|
||||
|
||||
Install complete.
|
||||
|
||||
Package dir: ${PACKAGE_DIR}
|
||||
Install directory: ${INSTALL_DIR}
|
||||
Binary: ${INSTALL_DIR}/${BIN_NAME}
|
||||
Frontend: ${INSTALL_DIR}/frontend
|
||||
Docs: ${INSTALL_DIR}/docs
|
||||
Service name: ${SERVICE_NAME}
|
||||
|
||||
Next steps:
|
||||
1. Edit ${INSTALL_DIR}/.env if needed
|
||||
2. Check service status: systemctl status ${SERVICE_NAME}
|
||||
3. Tail logs: journalctl -u ${SERVICE_NAME} -f
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
if [[ ! -f "${PACKAGE_DIR}/${BIN_NAME}" && -f "${SCRIPT_DIR}/../${BIN_NAME}" ]]; then
|
||||
PACKAGE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
fi
|
||||
ensure_root
|
||||
ensure_run_user
|
||||
ensure_package_layout
|
||||
prepare_dirs
|
||||
copy_runtime_files
|
||||
chown -R "${RUN_USER}:${RUN_USER}" "${INSTALL_DIR}" || true
|
||||
chmod +x "${INSTALL_DIR}/${BIN_NAME}" || true
|
||||
install_service
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user