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

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, "/")
}