first commit
All checks were successful
build / build (api, amd64, linux) (push) Successful in -47s
build / build (api, arm64, linux) (push) Successful in -48s
build / build (api.exe, amd64, windows) (push) Successful in -47s

This commit is contained in:
CN-JS-HuiBai
2026-04-17 09:49:16 +08:00
commit 1ed31b9292
73 changed files with 16458 additions and 0 deletions

17
.env.example Normal file
View 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

View 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
View File

@@ -0,0 +1,5 @@
AIAgentKey.pem
.env
reference/
development/
dist/

177
README.md Normal file
View 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 ./...`

BIN
api.exe Normal file

Binary file not shown.

92
cmd/api/main.go Normal file
View 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
View 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
View 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
View 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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
})();

View 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>

View 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>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
})();

View 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

File diff suppressed because one or more lines are too long

View 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
View 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
View 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
View 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
View 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
View 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")
}

View 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)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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
}

View 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": "注册成功",
})
}

View 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)
}

View 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,
})
}
}

View 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
}

View 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)
}

View 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

View 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")
}

View 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
}

View 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": "审核操作成功"})
}

View 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)
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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,
})
}

View 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 ""
}

View 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)
}

View 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()
}
}

View 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()
}
}

View 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()
}
}

View 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()
}
}

View 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
View 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"
}

View 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"
}

View 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
View 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
View 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
View 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"
}

View 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
View 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"
}

View 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"
}

View 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"
}

View 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
View 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"
}

View 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
View 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"
}

View 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
View 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"
}

View 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
}

View 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
}

View 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
View 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
View 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
View 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 ""
}

View 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
View 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
View 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
View 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 "$@"