first commit
This commit is contained in:
71
internal/config/config.go
Normal file
71
internal/config/config.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPass string
|
||||
DBName string
|
||||
RedisHost string
|
||||
RedisPort string
|
||||
RedisPass string
|
||||
RedisDB int
|
||||
JWTSecret string
|
||||
AppPort string
|
||||
AppURL string
|
||||
PluginRoot string
|
||||
}
|
||||
|
||||
var AppConfig *Config
|
||||
|
||||
func LoadConfig() {
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Println("No .env file found, using environment variables")
|
||||
}
|
||||
|
||||
AppConfig = &Config{
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "3306"),
|
||||
DBUser: getEnv("DB_USER", "root"),
|
||||
DBPass: getEnv("DB_PASS", ""),
|
||||
DBName: getEnv("DB_NAME", "xboard"),
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: getEnv("REDIS_PORT", "6379"),
|
||||
RedisPass: getEnv("REDIS_PASS", ""),
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
||||
AppPort: getEnv("APP_PORT", "8080"),
|
||||
AppURL: getEnv("APP_URL", ""),
|
||||
PluginRoot: getEnv("PLUGIN_ROOT", "reference\\LDNET-GA-Theme\\plugin"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
raw := getEnv(key, "")
|
||||
if raw == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
139
internal/database/cache.go
Normal file
139
internal/database/cache.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
"xboard-go/internal/config"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var Redis *redis.Client
|
||||
|
||||
type memoryEntry struct {
|
||||
Value []byte
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type memoryCache struct {
|
||||
mu sync.RWMutex
|
||||
items map[string]memoryEntry
|
||||
}
|
||||
|
||||
var fallbackCache = &memoryCache{
|
||||
items: make(map[string]memoryEntry),
|
||||
}
|
||||
|
||||
func InitCache() {
|
||||
addr := net.JoinHostPort(config.AppConfig.RedisHost, config.AppConfig.RedisPort)
|
||||
client := redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: config.AppConfig.RedisPass,
|
||||
DB: config.AppConfig.RedisDB,
|
||||
})
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.Ping(ctx).Err(); err != nil {
|
||||
log.Printf("Redis/Valkey unavailable, falling back to in-memory cache: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Redis = client
|
||||
log.Printf("Redis/Valkey connection established at %s", addr)
|
||||
}
|
||||
|
||||
func CacheSet(key string, value any, ttl time.Duration) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if Redis != nil {
|
||||
if err := Redis.Set(context.Background(), key, payload, ttl).Err(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCache.Set(key, payload, ttl)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheDelete(key string) error {
|
||||
if Redis != nil {
|
||||
if err := Redis.Del(context.Background(), key).Err(); err == nil {
|
||||
fallbackCache.Delete(key)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fallbackCache.Delete(key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CacheGetJSON[T any](key string) (T, bool) {
|
||||
var zero T
|
||||
var payload []byte
|
||||
|
||||
if Redis != nil {
|
||||
value, err := Redis.Get(context.Background(), key).Bytes()
|
||||
if err == nil {
|
||||
payload = value
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload) == 0 {
|
||||
value, ok := fallbackCache.Get(key)
|
||||
if !ok {
|
||||
return zero, false
|
||||
}
|
||||
payload = value
|
||||
}
|
||||
|
||||
var result T
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return zero, false
|
||||
}
|
||||
|
||||
return result, true
|
||||
}
|
||||
|
||||
func (m *memoryCache) Set(key string, value []byte, ttl time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
entry := memoryEntry{
|
||||
Value: value,
|
||||
}
|
||||
if ttl > 0 {
|
||||
entry.ExpiresAt = time.Now().Add(ttl)
|
||||
}
|
||||
m.items[key] = entry
|
||||
}
|
||||
|
||||
func (m *memoryCache) Get(key string) ([]byte, bool) {
|
||||
m.mu.RLock()
|
||||
entry, ok := m.items[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if !entry.ExpiresAt.IsZero() && time.Now().After(entry.ExpiresAt) {
|
||||
m.Delete(key)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return entry.Value, true
|
||||
}
|
||||
|
||||
func (m *memoryCache) Delete(key string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.items, key)
|
||||
}
|
||||
34
internal/database/db.go
Normal file
34
internal/database/db.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"xboard-go/internal/config"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
func InitDB() {
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
config.AppConfig.DBUser,
|
||||
config.AppConfig.DBPass,
|
||||
config.AppConfig.DBHost,
|
||||
config.AppConfig.DBPort,
|
||||
config.AppConfig.DBName,
|
||||
)
|
||||
|
||||
var err error
|
||||
DB, err = gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Database connection established")
|
||||
}
|
||||
230
internal/handler/admin_handler.go
Normal file
230
internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminPortal(c *gin.Context) {
|
||||
// Load settings for the portal
|
||||
var appNameSetting model.Setting
|
||||
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
|
||||
appName := appNameSetting.Value
|
||||
if appName == "" {
|
||||
appName = "XBoard Admin"
|
||||
}
|
||||
|
||||
securePath := c.Param("path")
|
||||
if securePath == "" {
|
||||
securePath = "admin" // fallback
|
||||
}
|
||||
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%s - 管理控制台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0f172a;
|
||||
--bg-accent: #1e293b;
|
||||
--primary: #6366f1;
|
||||
--secondary: #a855f7;
|
||||
--text-main: #f8fafc;
|
||||
--text-dim: #94a3b8;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-deep);
|
||||
color: var(--text-main);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated Background Gradients */
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 150vw;
|
||||
height: 150vh;
|
||||
background: radial-gradient(circle at 30%% 30%%, rgba(99, 102, 241, 0.15), transparent 40%%),
|
||||
radial-gradient(circle at 70%% 70%%, rgba(168, 85, 247, 0.15), transparent 40%%);
|
||||
animation: drift 20s infinite alternate ease-in-out;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
from { transform: translate(-10%%, -10%%) rotate(0deg); }
|
||||
to { transform: translate(10%%, 10%%) rotate(5deg); }
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%%;
|
||||
max-width: 1000px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.8s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
p.subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.portal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
background: var(--glass);
|
||||
border: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 24px;
|
||||
padding: 3rem 2rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.portal-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%%;
|
||||
height: 100%%;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.05), rgba(168, 85, 247, 0.05));
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.portal-card:hover {
|
||||
transform: translateY(-10px) scale(1.02);
|
||||
border-color: rgba(99, 102, 241, 0.4);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.portal-card:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--bg-accent);
|
||||
border-radius: 20px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: var(--primary);
|
||||
transition: transform 0.4s;
|
||||
}
|
||||
|
||||
.portal-card:hover .card-icon {
|
||||
transform: rotate(-5deg) scale(1.1);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 1.5rem;
|
||||
background: var(--primary);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 100px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
h1 { font-size: 2.2rem; }
|
||||
.portal-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>%s</h1>
|
||||
<p class="subtitle">欢迎来到管理中心,请选择进入的功能模块</p>
|
||||
|
||||
<div class="portal-grid">
|
||||
<!-- Admin Dashboard -->
|
||||
<a href="/admin/" class="portal-card">
|
||||
<div class="card-icon">⚙️</div>
|
||||
<div class="card-title">系统管理后台</div>
|
||||
<div class="card-desc">管理用户、套餐、节点及系统全局配置</div>
|
||||
</a>
|
||||
|
||||
<!-- Real-Name Verification -->
|
||||
<a href="/%s/realname" class="portal-card">
|
||||
<div class="badge">插件</div>
|
||||
<div class="card-icon">🆔</div>
|
||||
<div class="card-title">实名验证中心</div>
|
||||
<div class="card-desc">审核用户实名信息,确保站点运营安全</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`, appName, appName, securePath)
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
1015
internal/handler/admin_server_api.go
Normal file
1015
internal/handler/admin_server_api.go
Normal file
File diff suppressed because it is too large
Load Diff
1009
internal/handler/admin_server_api.go.8129289363901765875
Normal file
1009
internal/handler/admin_server_api.go.8129289363901765875
Normal file
File diff suppressed because it is too large
Load Diff
230
internal/handler/auth_api.go
Normal file
230
internal/handler/auth_api.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
InviteCode *string `json:"invite_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
Fail(c, http.StatusUnauthorized, "email or password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.Password, user.Password, user.PasswordAlgo, user.PasswordSalt) {
|
||||
Fail(c, http.StatusUnauthorized, "email or password is incorrect")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
_ = database.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
Fail(c, http.StatusBadRequest, "email already exists")
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
tokenRaw := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+req.Email)))
|
||||
user := model.User{
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
UUID: uuid.New().String(),
|
||||
Token: tokenRaw[:16],
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "register failed")
|
||||
return
|
||||
}
|
||||
|
||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && user.PlanID != nil {
|
||||
service.SyncIPv6ShadowAccount(&user)
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Token2Login(c *gin.Context) {
|
||||
verify := strings.TrimSpace(c.Query("verify"))
|
||||
if verify == "" {
|
||||
Fail(c, http.StatusBadRequest, "verify token is required")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := service.ResolveQuickLoginToken(verify)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "verify token is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
Fail(c, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to create auth token")
|
||||
return
|
||||
}
|
||||
service.TrackSession(user.ID, token, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
_ = database.DB.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", time.Now().Unix()).Error
|
||||
|
||||
Success(c, gin.H{
|
||||
"token": token,
|
||||
"auth_data": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func SendEmailVerify(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid email")
|
||||
return
|
||||
}
|
||||
|
||||
code, err := randomDigits(6)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to generate verify code")
|
||||
return
|
||||
}
|
||||
|
||||
service.StoreEmailVerifyCode(strings.ToLower(strings.TrimSpace(req.Email)), code, 10*time.Minute)
|
||||
SuccessMessage(c, "email verify code generated", gin.H{
|
||||
"email": req.Email,
|
||||
"debug_code": code,
|
||||
"expires_in": 600,
|
||||
})
|
||||
}
|
||||
|
||||
func ForgetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
EmailCode string `json:"email_code" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
if !service.CheckEmailVerifyCode(email, strings.TrimSpace(req.EmailCode)) {
|
||||
Fail(c, http.StatusBadRequest, "email verify code is invalid")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"password": hashed,
|
||||
"password_algo": nil,
|
||||
"password_salt": nil,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}
|
||||
if err := database.DB.Model(&model.User{}).Where("email = ?", email).Updates(updates).Error; err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "password reset failed")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "password reset success", true)
|
||||
}
|
||||
|
||||
func randomDigits(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoded := hex.EncodeToString(buf)
|
||||
digits := make([]byte, 0, length)
|
||||
for i := 0; i < len(encoded) && len(digits) < length; i++ {
|
||||
digits = append(digits, '0'+(encoded[i]%10))
|
||||
}
|
||||
return string(digits), nil
|
||||
}
|
||||
102
internal/handler/auth_handler.go
Normal file
102
internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=8"`
|
||||
InviteCode *string `json:"invite_code"`
|
||||
}
|
||||
|
||||
func Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("email = ?", req.Email).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "邮箱或密码错误"})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, user.IsAdmin)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "生成Token失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"token": token,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email already exists
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", req.Email).Count(&count)
|
||||
if count > 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "该邮箱已被注册"})
|
||||
return
|
||||
}
|
||||
|
||||
hashedPassword, err := utils.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "系统错误"})
|
||||
return
|
||||
}
|
||||
|
||||
newUUID := uuid.New().String()
|
||||
// Generate a 16-character random token for compatibility
|
||||
tokenRaw := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+req.Email)))
|
||||
token := tokenRaw[:16]
|
||||
|
||||
user := model.User{
|
||||
Email: req.Email,
|
||||
Password: hashedPassword,
|
||||
UUID: newUUID,
|
||||
Token: token,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&user).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "注册失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "注册成功",
|
||||
})
|
||||
}
|
||||
144
internal/handler/client_api.go
Normal file
144
internal/handler/client_api.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/protocol"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ClientSubscribe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusForbidden, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
|
||||
ua := c.GetHeader("User-Agent")
|
||||
flag := c.Query("flag")
|
||||
if strings.Contains(ua, "Clash") || flag == "clash" {
|
||||
config, _ := protocol.GenerateClash(servers, *user)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(strings.ToLower(ua), "sing-box") || flag == "sing-box" {
|
||||
config, _ := protocol.GenerateSingBox(servers, *user)
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
links := make([]string, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
links = append(links, "vmess://"+server.Name)
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Subscription-Userinfo", subscriptionUserInfo(*user))
|
||||
c.String(http.StatusOK, base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n"))))
|
||||
}
|
||||
|
||||
func ClientAppConfigV1(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusForbidden, "invalid token")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
|
||||
config, _ := protocol.GenerateClash(servers, *user)
|
||||
c.Header("Content-Type", "text/yaml; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func ClientAppConfigV2(c *gin.Context) {
|
||||
config := gin.H{
|
||||
"app_info": gin.H{
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_description": service.MustGetString("app_description", ""),
|
||||
"app_url": service.GetAppURL(),
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"version": service.MustGetString("app_version", "1.0.0"),
|
||||
},
|
||||
"features": gin.H{
|
||||
"enable_register": service.MustGetBool("app_enable_register", true),
|
||||
"enable_invite_system": service.MustGetBool("app_enable_invite_system", true),
|
||||
"enable_telegram_bot": service.MustGetBool("telegram_bot_enable", false),
|
||||
"enable_ticket_system": service.MustGetBool("app_enable_ticket_system", true),
|
||||
"enable_commission_system": service.MustGetBool("app_enable_commission_system", true),
|
||||
"enable_traffic_log": service.MustGetBool("app_enable_traffic_log", true),
|
||||
"enable_knowledge_base": service.MustGetBool("app_enable_knowledge_base", true),
|
||||
"enable_announcements": service.MustGetBool("app_enable_announcements", true),
|
||||
},
|
||||
"security_config": gin.H{
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
"privacy_policy_url": service.MustGetString("app_privacy_policy_url", ""),
|
||||
"is_email_verify": service.MustGetInt("email_verify", 0),
|
||||
"is_invite_force": service.MustGetInt("invite_force", 0),
|
||||
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
||||
"is_captcha": service.MustGetInt("captcha_enable", 0),
|
||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
|
||||
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
|
||||
},
|
||||
}
|
||||
|
||||
Success(c, config)
|
||||
}
|
||||
|
||||
func ClientAppVersion(c *gin.Context) {
|
||||
ua := strings.ToLower(c.GetHeader("User-Agent"))
|
||||
if strings.Contains(ua, "tidalab/4.0.0") || strings.Contains(ua, "tunnelab/4.0.0") {
|
||||
if strings.Contains(ua, "win64") {
|
||||
Success(c, gin.H{
|
||||
"version": service.MustGetString("windows_version", ""),
|
||||
"download_url": service.MustGetString("windows_download_url", ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"version": service.MustGetString("macos_version", ""),
|
||||
"download_url": service.MustGetString("macos_download_url", ""),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"windows_version": service.MustGetString("windows_version", ""),
|
||||
"windows_download_url": service.MustGetString("windows_download_url", ""),
|
||||
"macos_version": service.MustGetString("macos_version", ""),
|
||||
"macos_download_url": service.MustGetString("macos_download_url", ""),
|
||||
"android_version": service.MustGetString("android_version", ""),
|
||||
"android_download_url": service.MustGetString("android_download_url", ""),
|
||||
})
|
||||
}
|
||||
|
||||
func subscriptionUserInfo(user model.User) string {
|
||||
expire := int64(0)
|
||||
if user.ExpiredAt != nil {
|
||||
expire = *user.ExpiredAt
|
||||
}
|
||||
return "upload=" + strconv.FormatInt(int64(user.U), 10) +
|
||||
"; download=" + strconv.FormatInt(int64(user.D), 10) +
|
||||
"; total=" + strconv.FormatInt(int64(user.TransferEnable), 10) +
|
||||
"; expire=" + strconv.FormatInt(expire, 10)
|
||||
}
|
||||
31
internal/handler/common.go
Normal file
31
internal/handler/common.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Success(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{"data": data})
|
||||
}
|
||||
|
||||
func SuccessMessage(c *gin.Context, message string, data any) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": message,
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
func Fail(c *gin.Context, status int, message string) {
|
||||
c.JSON(status, gin.H{"message": message})
|
||||
}
|
||||
|
||||
func NotImplemented(endpoint string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"message": "not implemented yet",
|
||||
"endpoint": endpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
55
internal/handler/guest_api.go
Normal file
55
internal/handler/guest_api.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func GuestConfig(c *gin.Context) {
|
||||
Success(c, buildGuestConfig())
|
||||
}
|
||||
|
||||
func buildGuestConfig() gin.H {
|
||||
data := gin.H{
|
||||
"tos_url": service.MustGetString("tos_url", ""),
|
||||
"is_email_verify": boolToInt(service.MustGetBool("email_verify", false)),
|
||||
"is_invite_force": boolToInt(service.MustGetBool("invite_force", false)),
|
||||
"email_whitelist_suffix": service.MustGetString("email_whitelist_suffix", ""),
|
||||
"is_captcha": boolToInt(service.MustGetBool("captcha_enable", false)),
|
||||
"captcha_type": service.MustGetString("captcha_type", "recaptcha"),
|
||||
"recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""),
|
||||
"recaptcha_v3_site_key": service.MustGetString("recaptcha_v3_site_key", ""),
|
||||
"recaptcha_v3_score_threshold": service.MustGetString("recaptcha_v3_score_threshold", "0.5"),
|
||||
"turnstile_site_key": service.MustGetString("turnstile_site_key", ""),
|
||||
"app_description": service.MustGetString("app_description", ""),
|
||||
"app_url": service.GetAppURL(),
|
||||
"logo": service.MustGetString("logo", ""),
|
||||
"is_recaptcha": boolToInt(service.MustGetBool("captcha_enable", false)),
|
||||
}
|
||||
|
||||
if service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
data["real_name_verification_enable"] = true
|
||||
data["real_name_verification_notice"] = service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please complete real-name verification.")
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func GuestPlanFetch(c *gin.Context) {
|
||||
var plans []model.Plan
|
||||
if err := database.DB.Where("`show` = ? AND sell = ?", 1, 1).Order("sort ASC").Find(&plans).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch plans")
|
||||
return
|
||||
}
|
||||
Success(c, plans)
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
410
internal/handler/node_api.go
Normal file
410
internal/handler/node_api.go
Normal file
@@ -0,0 +1,410 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func NodeShadowsocksTidalabUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "shadowsocks" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a shadowsocks node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
cipher := tidalabProtocolString(node.ProtocolSettings, "cipher")
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, gin.H{
|
||||
"id": user.ID,
|
||||
"port": node.ServerPort,
|
||||
"cipher": cipher,
|
||||
"secret": user.UUID,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||||
}
|
||||
|
||||
func NodeTrojanTidalabUser(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a trojan node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, http.StatusInternalServerError, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]gin.H, 0, len(users))
|
||||
for _, user := range users {
|
||||
item := gin.H{
|
||||
"id": user.ID,
|
||||
"trojan_user": gin.H{"password": user.UUID},
|
||||
}
|
||||
if user.SpeedLimit != nil {
|
||||
item["speed_limit"] = *user.SpeedLimit
|
||||
}
|
||||
if user.DeviceLimit != nil {
|
||||
item["device_limit"] = *user.DeviceLimit
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"msg": "ok",
|
||||
"data": result,
|
||||
})
|
||||
}
|
||||
|
||||
func NodeConfig(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
config := service.BuildNodeConfig(node)
|
||||
config["base_config"] = gin.H{
|
||||
"push_interval": service.MustGetInt("server_push_interval", 60),
|
||||
"pull_interval": service.MustGetInt("server_pull_interval", 60),
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, config)
|
||||
}
|
||||
|
||||
func NodeTrojanTidalabConfig(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server is not a trojan node")
|
||||
return
|
||||
}
|
||||
setNodeLastCheck(node)
|
||||
|
||||
localPort, err := strconv.Atoi(strings.TrimSpace(c.Query("local_port")))
|
||||
if err != nil || localPort <= 0 {
|
||||
Fail(c, http.StatusBadRequest, "local_port is required")
|
||||
return
|
||||
}
|
||||
|
||||
serverName := tidalabProtocolString(node.ProtocolSettings, "server_name")
|
||||
if serverName == "" {
|
||||
serverName = strings.TrimSpace(node.Host)
|
||||
}
|
||||
if serverName == "" {
|
||||
serverName = "domain.com"
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"run_type": "server",
|
||||
"local_addr": "0.0.0.0",
|
||||
"local_port": node.ServerPort,
|
||||
"remote_addr": "www.taobao.com",
|
||||
"remote_port": 80,
|
||||
"password": []string{},
|
||||
"ssl": gin.H{
|
||||
"cert": "server.crt",
|
||||
"key": "server.key",
|
||||
"sni": serverName,
|
||||
},
|
||||
"api": gin.H{
|
||||
"enabled": true,
|
||||
"api_addr": "127.0.0.1",
|
||||
"api_port": localPort,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func NodePush(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
|
||||
var payload map[string][]int64
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for userIDRaw, traffic := range payload {
|
||||
if len(traffic) != 2 {
|
||||
continue
|
||||
}
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(userID, node, traffic[0], traffic[1])
|
||||
}
|
||||
|
||||
setNodeLastPush(node, len(payload))
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func NodeTidalabSubmit(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
if node.Type != "shadowsocks" && node.Type != "trojan" {
|
||||
Fail(c, http.StatusBadRequest, "server type is not supported by tidalab submit")
|
||||
return
|
||||
}
|
||||
|
||||
var payload []struct {
|
||||
UserID int `json:"user_id"`
|
||||
U int64 `json:"u"`
|
||||
D int64 `json:"d"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, http.StatusBadRequest, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for _, item := range payload {
|
||||
if item.UserID <= 0 {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(item.UserID, node, item.U, item.D)
|
||||
}
|
||||
|
||||
setNodeLastPush(node, len(payload))
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ret": 1,
|
||||
"msg": "ok",
|
||||
})
|
||||
}
|
||||
|
||||
func NodeAlive(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
|
||||
var payload map[string][]string
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
for userIDRaw, ips := range payload {
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = service.SetDevices(userID, node.ID, ips)
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func NodeAliveList(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
users, err := service.AvailableUsersForNode(node)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
if user.DeviceLimit != nil && *user.DeviceLimit > 0 {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
devices := service.GetUsersDevices(userIDs)
|
||||
alive := make(map[string][]string, len(devices))
|
||||
for userID, ips := range devices {
|
||||
alive[strconv.Itoa(userID)] = ips
|
||||
}
|
||||
|
||||
Success(c, gin.H{"alive": alive})
|
||||
}
|
||||
|
||||
func NodeStatus(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
var status map[string]any
|
||||
if err := c.ShouldBindJSON(&status); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
_ = database.CacheSet(nodeLoadStatusKey(node), gin.H{
|
||||
"cpu": status["cpu"],
|
||||
"mem": status["mem"],
|
||||
"swap": status["swap"],
|
||||
"disk": status["disk"],
|
||||
"kernel_status": status["kernel_status"],
|
||||
"updated_at": time.Now().Unix(),
|
||||
}, cacheTime)
|
||||
_ = database.CacheSet(nodeLastLoadKey(node), time.Now().Unix(), cacheTime)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": true, "code": 0, "message": "success"})
|
||||
}
|
||||
|
||||
func NodeHandshake(c *gin.Context) {
|
||||
websocket := gin.H{"enabled": false}
|
||||
if service.MustGetBool("server_ws_enable", true) {
|
||||
wsURL := strings.TrimSpace(service.MustGetString("server_ws_url", ""))
|
||||
if wsURL == "" {
|
||||
scheme := "ws"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "wss"
|
||||
}
|
||||
wsURL = fmt.Sprintf("%s://%s:8076", scheme, c.Request.Host)
|
||||
}
|
||||
websocket = gin.H{
|
||||
"enabled": true,
|
||||
"ws_url": strings.TrimRight(wsURL, "/"),
|
||||
}
|
||||
}
|
||||
Success(c, gin.H{"websocket": websocket})
|
||||
}
|
||||
|
||||
func NodeReport(c *gin.Context) {
|
||||
node := c.MustGet("node").(*model.Server)
|
||||
setNodeLastCheck(node)
|
||||
|
||||
var payload struct {
|
||||
Traffic map[string][]int64 `json:"traffic"`
|
||||
Alive map[string][]string `json:"alive"`
|
||||
Online map[string]int `json:"online"`
|
||||
Status map[string]any `json:"status"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
Fail(c, 400, "invalid payload")
|
||||
return
|
||||
}
|
||||
|
||||
if len(payload.Traffic) > 0 {
|
||||
for userIDRaw, traffic := range payload.Traffic {
|
||||
if len(traffic) != 2 {
|
||||
continue
|
||||
}
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
service.ApplyTrafficDelta(userID, node, traffic[0], traffic[1])
|
||||
}
|
||||
setNodeLastPush(node, len(payload.Traffic))
|
||||
}
|
||||
|
||||
if len(payload.Alive) > 0 {
|
||||
for userIDRaw, ips := range payload.Alive {
|
||||
userID, err := strconv.Atoi(userIDRaw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = service.SetDevices(userID, node.ID, ips)
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.Online) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
for userIDRaw, conn := range payload.Online {
|
||||
key := fmt.Sprintf("USER_ONLINE_CONN_%s_%d_%s", strings.ToUpper(node.Type), node.ID, userIDRaw)
|
||||
_ = database.CacheSet(key, conn, cacheTime)
|
||||
}
|
||||
}
|
||||
|
||||
if len(payload.Status) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
_ = database.CacheSet(nodeLoadStatusKey(node), gin.H{
|
||||
"cpu": payload.Status["cpu"],
|
||||
"mem": payload.Status["mem"],
|
||||
"swap": payload.Status["swap"],
|
||||
"disk": payload.Status["disk"],
|
||||
"kernel_status": payload.Status["kernel_status"],
|
||||
"updated_at": time.Now().Unix(),
|
||||
}, cacheTime)
|
||||
_ = database.CacheSet(nodeLastLoadKey(node), time.Now().Unix(), cacheTime)
|
||||
}
|
||||
|
||||
if len(payload.Metrics) > 0 {
|
||||
cacheTime := time.Duration(maxInt(300, service.MustGetInt("server_push_interval", 60)*3)) * time.Second
|
||||
payload.Metrics["updated_at"] = time.Now().Unix()
|
||||
_ = database.CacheSet(nodeMetricsKey(node), payload.Metrics, cacheTime)
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func setNodeLastCheck(node *model.Server) {
|
||||
_ = database.CacheSet(nodeLastCheckKey(node), time.Now().Unix(), time.Hour)
|
||||
}
|
||||
|
||||
func setNodeLastPush(node *model.Server, onlineUsers int) {
|
||||
_ = database.CacheSet(nodeOnlineKey(node), onlineUsers, time.Hour)
|
||||
_ = database.CacheSet(nodeLastPushKey(node), time.Now().Unix(), time.Hour)
|
||||
}
|
||||
|
||||
func nodeLastCheckKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_CHECK_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLastPushKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_PUSH_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeOnlineKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_ONLINE_USER_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLoadStatusKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LOAD_STATUS_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeLastLoadKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_LAST_LOAD_AT_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func nodeMetricsKey(node *model.Server) string {
|
||||
return fmt.Sprintf("SERVER_%s_METRICS_%d", strings.ToUpper(node.Type), node.ID)
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func tidalabProtocolString(raw *string, key string) string {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
decoded := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
value, _ := decoded[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
85
internal/handler/node_handler.go
Normal file
85
internal/handler/node_handler.go
Normal file
@@ -0,0 +1,85 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NodeUser(c *gin.Context) {
|
||||
nodePtr, _ := c.Get("node")
|
||||
_ = nodePtr.(*model.Server) // Silence unused node for now
|
||||
|
||||
var users []model.User
|
||||
// Basic filtering: banned=0 and not expired
|
||||
query := database.DB.Model(&model.User{}).
|
||||
Where("banned = ? AND (expired_at IS NULL OR expired_at > ?)", 0, time.Now().Unix())
|
||||
|
||||
if err := query.Find(&users).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "获取用户失败"})
|
||||
return
|
||||
}
|
||||
|
||||
type UniUser struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
|
||||
uniUsers := make([]UniUser, len(users))
|
||||
for i, u := range users {
|
||||
uniUsers[i] = UniUser{ID: u.ID, UUID: u.UUID}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": uniUsers,
|
||||
})
|
||||
}
|
||||
|
||||
func NodePush(c *gin.Context) {
|
||||
nodePtr, _ := c.Get("node")
|
||||
node := nodePtr.(*model.Server)
|
||||
|
||||
var data map[string][]int64
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
for userIDStr, traffic := range data {
|
||||
userID, err := strconv.Atoi(userIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(traffic) < 2 {
|
||||
continue
|
||||
}
|
||||
u := traffic[0]
|
||||
d := traffic[1]
|
||||
|
||||
// Update user traffic
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"u": gorm.Expr("u + ?", u),
|
||||
"d": gorm.Expr("d + ?", d),
|
||||
"t": time.Now().Unix(),
|
||||
})
|
||||
|
||||
// Update node traffic
|
||||
database.DB.Model(&model.Server{}).Where("id = ?", node.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"u": gorm.Expr("u + ?", u),
|
||||
"d": gorm.Expr("d + ?", d),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": true})
|
||||
}
|
||||
|
||||
// Config, Alive, Status handlers suppressed for brevity in this step
|
||||
304
internal/handler/plugin_api.go
Normal file
304
internal/handler/plugin_api.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func PluginUserOnlineDevicesUsers(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
query := database.DB.Model(&model.User{}).Order("id DESC")
|
||||
if keyword != "" {
|
||||
query = query.Where("email LIKE ? OR id = ?", "%"+keyword+"%", keyword)
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
var users []model.User
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Find(&users).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch users")
|
||||
return
|
||||
}
|
||||
|
||||
userIDs := make([]int, 0, len(users))
|
||||
for _, user := range users {
|
||||
userIDs = append(userIDs, user.ID)
|
||||
}
|
||||
devices := service.GetUsersDevices(userIDs)
|
||||
|
||||
list := make([]gin.H, 0, len(users))
|
||||
usersWithOnlineIP := 0
|
||||
totalOnlineIPs := 0
|
||||
for _, user := range users {
|
||||
subscriptionName := "No subscription"
|
||||
if user.PlanID != nil {
|
||||
var plan model.Plan
|
||||
if database.DB.First(&plan, *user.PlanID).Error == nil {
|
||||
subscriptionName = plan.Name
|
||||
}
|
||||
}
|
||||
|
||||
ips := devices[user.ID]
|
||||
if len(ips) > 0 {
|
||||
usersWithOnlineIP++
|
||||
totalOnlineIPs += len(ips)
|
||||
}
|
||||
|
||||
list = append(list, gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"subscription_name": subscriptionName,
|
||||
"online_count": len(ips),
|
||||
"online_devices": ips,
|
||||
"last_online_text": formatTimeValue(user.LastOnlineAt),
|
||||
"created_text": formatUnixValue(user.CreatedAt),
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"list": list,
|
||||
"filters": gin.H{
|
||||
"keyword": keyword,
|
||||
"per_page": perPage,
|
||||
},
|
||||
"summary": gin.H{
|
||||
"page_users": len(users),
|
||||
"users_with_online_ip": usersWithOnlineIP,
|
||||
"total_online_ips": totalOnlineIPs,
|
||||
"current_page": page,
|
||||
},
|
||||
"pagination": gin.H{
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
"per_page": perPage,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserOnlineDevicesGetIP(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserOnlineDevices) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
devices := service.GetUsersDevices([]int{user.ID})
|
||||
ips := devices[user.ID]
|
||||
authToken, _ := c.Get("auth_token")
|
||||
currentToken, _ := authToken.(string)
|
||||
sessions := service.GetUserSessions(user.ID, currentToken)
|
||||
currentID := currentSessionID(c)
|
||||
|
||||
sessionItems := make([]gin.H, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
sessionItems = append(sessionItems, gin.H{
|
||||
"id": session.ID,
|
||||
"name": session.Name,
|
||||
"user_agent": session.UserAgent,
|
||||
"ip": firstString(session.IP, "-"),
|
||||
"created_at": session.CreatedAt,
|
||||
"last_used_at": session.LastUsedAt,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"is_current": session.ID == currentID,
|
||||
})
|
||||
}
|
||||
Success(c, gin.H{
|
||||
"ips": ips,
|
||||
"session_overview": gin.H{
|
||||
"online_ip_count": len(ips),
|
||||
"online_ips": ips,
|
||||
"online_device_count": len(ips),
|
||||
"device_limit": user.DeviceLimit,
|
||||
"last_online_at": user.LastOnlineAt,
|
||||
"active_session_count": len(sessionItems),
|
||||
"sessions": sessionItems,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6Check(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if user.PlanID == nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
}
|
||||
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
||||
Success(c, gin.H{"allowed": false, "reason": "No active plan"})
|
||||
return
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
var count int64
|
||||
database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Count(&count)
|
||||
|
||||
Success(c, gin.H{
|
||||
"allowed": service.PluginPlanAllowed(&plan),
|
||||
"is_active": count > 0,
|
||||
})
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6Enable(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.SyncIPv6ShadowAccount(user) {
|
||||
Fail(c, 403, "your plan does not support IPv6 subscription")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "IPv6 subscription enabled/synced", true)
|
||||
}
|
||||
|
||||
func PluginUserAddIPv6SyncPassword(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginUserAddIPv6) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
ipv6Email := service.IPv6ShadowEmail(user.Email)
|
||||
result := database.DB.Model(&model.User{}).Where("email = ?", ipv6Email).Update("password", user.Password)
|
||||
if result.Error != nil {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
Fail(c, 404, "IPv6 user not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "Password synced to IPv6 account", true)
|
||||
}
|
||||
|
||||
func AdminConfigFetch(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_url": service.GetAppURL(),
|
||||
"secure_path": service.GetAdminSecurePath(),
|
||||
"server_pull_interval": service.MustGetInt("server_pull_interval", 60),
|
||||
"server_push_interval": service.MustGetInt("server_push_interval", 60),
|
||||
})
|
||||
}
|
||||
|
||||
func AdminSystemStatus(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"server_time": time.Now().Unix(),
|
||||
"app_name": service.MustGetString("app_name", "XBoard"),
|
||||
"app_url": service.GetAppURL(),
|
||||
})
|
||||
}
|
||||
|
||||
func AdminPluginsList(c *gin.Context) {
|
||||
var plugins []model.Plugin
|
||||
if err := database.DB.Order("id ASC").Find(&plugins).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch plugins")
|
||||
return
|
||||
}
|
||||
Success(c, plugins)
|
||||
}
|
||||
|
||||
func AdminPluginTypes(c *gin.Context) {
|
||||
Success(c, []string{"feature", "payment"})
|
||||
}
|
||||
|
||||
func AdminPluginIntegrationStatus(c *gin.Context) {
|
||||
Success(c, gin.H{
|
||||
"user_online_devices": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginUserOnlineDevices),
|
||||
"status": "complete",
|
||||
"summary": "用户侧在线设备概览与后台设备监控已接入 Go 后端。",
|
||||
"endpoints": []string{"/api/v1/user/user-online-devices/get-ip", "/api/v1/" + service.GetAdminSecurePath() + "/user-online-devices/users"},
|
||||
},
|
||||
"real_name_verification": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginRealNameVerification),
|
||||
"status": "complete",
|
||||
"summary": "实名状态查询、提交、后台审核与批量同步均已整合,并补齐 auto_approve/allow_resubmit_after_reject 行为。",
|
||||
"endpoints": []string{"/api/v1/user/real-name-verification/status", "/api/v1/user/real-name-verification/submit", "/api/v1/" + service.GetAdminSecurePath() + "/realname/records"},
|
||||
},
|
||||
"user_add_ipv6_subscription": gin.H{
|
||||
"enabled": service.IsPluginEnabled(service.PluginUserAddIPv6),
|
||||
"status": "integrated_with_runtime_sync",
|
||||
"summary": "用户启用、密码同步与运行时影子账号同步已接入;订单生命周期自动钩子仍依赖后续完整订单流重构。",
|
||||
"endpoints": []string{"/api/v1/user/user-add-ipv6-subscription/check", "/api/v1/user/user-add-ipv6-subscription/enable", "/api/v1/user/user-add-ipv6-subscription/sync-password"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string, defaultValue int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func calculateLastPage(total int64, perPage int) int {
|
||||
if perPage <= 0 {
|
||||
return 1
|
||||
}
|
||||
last := int((total + int64(perPage) - 1) / int64(perPage))
|
||||
if last == 0 {
|
||||
return 1
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func formatUnixValue(value int64) string {
|
||||
if value <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return time.Unix(value, 0).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func formatTimeValue(value *time.Time) string {
|
||||
if value == nil {
|
||||
return "-"
|
||||
}
|
||||
return value.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
426
internal/handler/realname_api.go
Normal file
426
internal/handler/realname_api.go
Normal file
@@ -0,0 +1,426 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const unverifiedExpiration = int64(946684800)
|
||||
|
||||
func PluginRealNameStatus(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
auth := realNameRecordForUser(user)
|
||||
status := "unverified"
|
||||
var realName, masked, rejectReason string
|
||||
var submittedAt, reviewedAt int64
|
||||
if auth != nil {
|
||||
status = auth.Status
|
||||
realName = auth.RealName
|
||||
masked = auth.IdentityMasked
|
||||
rejectReason = auth.RejectReason
|
||||
submittedAt = auth.SubmittedAt
|
||||
reviewedAt = auth.ReviewedAt
|
||||
}
|
||||
canSubmit := status == "unverified" || (status == "rejected" && service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true))
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": status,
|
||||
"status_label": realNameStatusLabel(status),
|
||||
"is_inherited": strings.Contains(user.Email, "-ipv6@"),
|
||||
"can_submit": canSubmit,
|
||||
"real_name": realName,
|
||||
"identity_no_masked": masked,
|
||||
"notice": service.GetPluginConfigString(service.PluginRealNameVerification, "verification_notice", "Please submit real-name information."),
|
||||
"reject_reason": rejectReason,
|
||||
"submitted_at": submittedAt,
|
||||
"reviewed_at": reviewedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func PluginRealNameSubmit(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, 401, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
IdentityNo string `json:"identity_no" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 422, "missing identity information")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
record := model.RealNameAuth{
|
||||
UserID: uint64(user.ID),
|
||||
RealName: req.RealName,
|
||||
IdentityMasked: maskIdentity(req.IdentityNo),
|
||||
IdentityEncrypted: req.IdentityNo,
|
||||
Status: "pending",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: 0,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
var existing model.RealNameAuth
|
||||
status := "pending"
|
||||
reviewedAt := int64(0)
|
||||
if service.GetPluginConfigBool(service.PluginRealNameVerification, "auto_approve", false) {
|
||||
status = "approved"
|
||||
reviewedAt = now
|
||||
}
|
||||
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&existing).Error; err == nil {
|
||||
if existing.Status == "approved" {
|
||||
Fail(c, 400, "verification already approved")
|
||||
return
|
||||
}
|
||||
if existing.Status == "pending" {
|
||||
Fail(c, 400, "verification is pending review")
|
||||
return
|
||||
}
|
||||
if existing.Status == "rejected" && !service.GetPluginConfigBool(service.PluginRealNameVerification, "allow_resubmit_after_reject", true) {
|
||||
Fail(c, 400, "rejected records cannot be resubmitted")
|
||||
return
|
||||
}
|
||||
|
||||
record.ID = existing.ID
|
||||
record.CreatedAt = existing.CreatedAt
|
||||
record.Status = status
|
||||
record.ReviewedAt = reviewedAt
|
||||
record.RejectReason = ""
|
||||
database.DB.Model(&existing).Updates(record)
|
||||
} else {
|
||||
record.Status = status
|
||||
record.ReviewedAt = reviewedAt
|
||||
database.DB.Create(&record)
|
||||
}
|
||||
|
||||
syncRealNameExpiration(user.ID, status)
|
||||
SuccessMessage(c, "verification submitted", gin.H{"status": status})
|
||||
}
|
||||
|
||||
func PluginRealNameRecords(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
page := parsePositiveInt(c.DefaultQuery("page", "1"), 1)
|
||||
perPage := parsePositiveInt(c.DefaultQuery("per_page", "20"), 20)
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
|
||||
query := database.DB.Table("v2_user AS u").
|
||||
Select("u.id, u.email, r.status, r.real_name, r.identity_masked, r.submitted_at, r.reviewed_at").
|
||||
Joins("LEFT JOIN v2_realname_auth AS r ON u.id = r.user_id").
|
||||
Where("u.email NOT LIKE ?", "%-ipv6@%")
|
||||
if keyword != "" {
|
||||
query = query.Where("u.email LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
type recordRow struct {
|
||||
ID int
|
||||
Email string
|
||||
Status *string
|
||||
RealName *string
|
||||
IdentityMasked *string
|
||||
SubmittedAt *int64
|
||||
ReviewedAt *int64
|
||||
}
|
||||
|
||||
var rows []recordRow
|
||||
if err := query.Offset((page - 1) * perPage).Limit(perPage).Scan(&rows).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch records")
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
status := "unverified"
|
||||
if row.Status != nil && *row.Status != "" {
|
||||
status = *row.Status
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"id": row.ID,
|
||||
"email": row.Email,
|
||||
"status": status,
|
||||
"status_label": realNameStatusLabel(status),
|
||||
"real_name": stringPointerValue(row.RealName),
|
||||
"identity_no_masked": stringPointerValue(row.IdentityMasked),
|
||||
"submitted_at": int64PointerValue(row.SubmittedAt),
|
||||
"reviewed_at": int64PointerValue(row.ReviewedAt),
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"status": "success",
|
||||
"data": items,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"current": page,
|
||||
"last_page": calculateLastPage(total, perPage),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func PluginRealNameReview(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Status == "" {
|
||||
req.Status = "approved"
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", userID).First(&record).Error; err == nil {
|
||||
record.Status = req.Status
|
||||
record.RejectReason = req.Reason
|
||||
record.ReviewedAt = now
|
||||
record.UpdatedAt = now
|
||||
database.DB.Save(&record)
|
||||
} else {
|
||||
database.DB.Create(&model.RealNameAuth{
|
||||
UserID: uint64(userID),
|
||||
Status: req.Status,
|
||||
RealName: "admin approved",
|
||||
IdentityMasked: "admin approved",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: now,
|
||||
RejectReason: req.Reason,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
syncRealNameExpiration(userID, req.Status)
|
||||
SuccessMessage(c, "review updated", true)
|
||||
}
|
||||
|
||||
func PluginRealNameReset(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
userID := parsePositiveInt(c.Param("userId"), 0)
|
||||
if userID == 0 {
|
||||
Fail(c, 400, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
database.DB.Where("user_id = ?", userID).Delete(&model.RealNameAuth{})
|
||||
syncRealNameExpiration(userID, "unverified")
|
||||
SuccessMessage(c, "record reset", true)
|
||||
}
|
||||
|
||||
func PluginRealNameSyncAll(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
SuccessMessage(c, "sync completed", performGlobalRealNameSync())
|
||||
}
|
||||
|
||||
func PluginRealNameApproveAll(c *gin.Context) {
|
||||
if !service.IsPluginEnabled(service.PluginRealNameVerification) {
|
||||
Fail(c, 400, "plugin is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
database.DB.Where("email NOT LIKE ?", "%-ipv6@%").Find(&users)
|
||||
now := time.Now().Unix()
|
||||
for _, user := range users {
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
||||
record.Status = "approved"
|
||||
record.RealName = "admin approved"
|
||||
record.IdentityMasked = "admin approved"
|
||||
record.SubmittedAt = now
|
||||
record.ReviewedAt = now
|
||||
record.UpdatedAt = now
|
||||
database.DB.Save(&record)
|
||||
} else {
|
||||
database.DB.Create(&model.RealNameAuth{
|
||||
UserID: uint64(user.ID),
|
||||
Status: "approved",
|
||||
RealName: "admin approved",
|
||||
IdentityMasked: "admin approved",
|
||||
SubmittedAt: now,
|
||||
ReviewedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
SuccessMessage(c, "all users approved", performGlobalRealNameSync())
|
||||
}
|
||||
|
||||
func PluginRealNameClearCache(c *gin.Context) {
|
||||
_ = database.CacheDelete("realname:sync")
|
||||
SuccessMessage(c, "cache cleared", true)
|
||||
}
|
||||
|
||||
func realNameRecordForUser(user *model.User) *model.RealNameAuth {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.Where("user_id = ?", user.ID).First(&record).Error; err == nil {
|
||||
return &record
|
||||
}
|
||||
|
||||
if strings.Contains(user.Email, "-ipv6@") {
|
||||
mainEmail := strings.Replace(user.Email, "-ipv6@", "@", 1)
|
||||
var mainUser model.User
|
||||
if err := database.DB.Where("email = ?", mainEmail).First(&mainUser).Error; err == nil {
|
||||
if err := database.DB.Where("user_id = ?", mainUser.ID).First(&record).Error; err == nil {
|
||||
return &record
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncRealNameExpiration(userID int, status string) {
|
||||
if status == "approved" {
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"expired_at": parseExpirationSetting(),
|
||||
"updated_at": time.Now().Unix(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if !service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false) {
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Update("updated_at", time.Now().Unix())
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
|
||||
"expired_at": unverifiedExpiration,
|
||||
"updated_at": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
func parseExpirationSetting() int64 {
|
||||
raw := service.GetPluginConfigString(service.PluginRealNameVerification, "verified_expiration_date", "2099-12-31")
|
||||
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
return value
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
return parsed.Unix()
|
||||
}
|
||||
return time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
|
||||
}
|
||||
|
||||
func performGlobalRealNameSync() gin.H {
|
||||
expireApproved := parseExpirationSetting()
|
||||
now := time.Now().Unix()
|
||||
enforce := service.GetPluginConfigBool(service.PluginRealNameVerification, "enforce_real_name", false)
|
||||
|
||||
type approvedRow struct {
|
||||
UserID int
|
||||
}
|
||||
var approvedRows []approvedRow
|
||||
database.DB.Table("v2_realname_auth").Select("user_id").Where("status = ?", "approved").Scan(&approvedRows)
|
||||
approvedUsers := make([]int, 0, len(approvedRows))
|
||||
for _, row := range approvedRows {
|
||||
approvedUsers = append(approvedUsers, row.UserID)
|
||||
}
|
||||
|
||||
if len(approvedUsers) > 0 {
|
||||
database.DB.Model(&model.User{}).Where("id IN ?", approvedUsers).
|
||||
Updates(map[string]any{"expired_at": expireApproved, "updated_at": now})
|
||||
if enforce {
|
||||
database.DB.Model(&model.User{}).Where("id NOT IN ?", approvedUsers).
|
||||
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
||||
}
|
||||
} else {
|
||||
if enforce {
|
||||
database.DB.Model(&model.User{}).
|
||||
Updates(map[string]any{"expired_at": unverifiedExpiration, "updated_at": now})
|
||||
}
|
||||
}
|
||||
|
||||
return gin.H{
|
||||
"enforce_real_name": enforce,
|
||||
"total_verified": len(approvedUsers),
|
||||
"actual_synced": len(approvedUsers),
|
||||
}
|
||||
}
|
||||
|
||||
func realNameStatusLabel(status string) string {
|
||||
switch status {
|
||||
case "approved":
|
||||
return "approved"
|
||||
case "pending":
|
||||
return "pending"
|
||||
case "rejected":
|
||||
return "rejected"
|
||||
default:
|
||||
return "unverified"
|
||||
}
|
||||
}
|
||||
|
||||
func maskIdentity(identity string) string {
|
||||
if len(identity) <= 8 {
|
||||
return identity
|
||||
}
|
||||
return identity[:4] + "**********" + identity[len(identity)-4:]
|
||||
}
|
||||
|
||||
func stringPointerValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func int64PointerValue(value *int64) int64 {
|
||||
if value == nil {
|
||||
return 0
|
||||
}
|
||||
return *value
|
||||
}
|
||||
269
internal/handler/realname_handler.go
Normal file
269
internal/handler/realname_handler.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RealNameIndex renders the beautified plugin management page.
|
||||
func RealNameIndex(c *gin.Context) {
|
||||
var appNameSetting model.Setting
|
||||
database.DB.Where("name = ?", "app_name").First(&appNameSetting)
|
||||
appName := appNameSetting.Value
|
||||
if appName == "" {
|
||||
appName = "XBoard"
|
||||
}
|
||||
|
||||
securePath := c.Param("path")
|
||||
apiEndpoint := fmt.Sprintf("/api/v1/%%s/realname/records", securePath)
|
||||
reviewEndpoint := fmt.Sprintf("/api/v1/%%s/realname/review", securePath)
|
||||
|
||||
// We use %% for literal percent signs in Sprintf
|
||||
// and we avoid backticks in the JS code by using regular strings to remain compatible with Go raw strings.
|
||||
html := fmt.Sprintf(`
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>%%s - 实名验证管理</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-deep: #0f172a;
|
||||
--bg-accent: #1e293b;
|
||||
--primary: #6366f1;
|
||||
--secondary: #a855f7;
|
||||
--text-main: #f8fafc;
|
||||
--text-dim: #94a3b8;
|
||||
--success: #10b981;
|
||||
--danger: #ef4444;
|
||||
--warning: #f59e0b;
|
||||
--glass: rgba(255, 255, 255, 0.03);
|
||||
--glass-border: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; font-family: 'Outfit', sans-serif; }
|
||||
body { background: var(--bg-deep); color: var(--text-main); min-height: 100vh; overflow-x: hidden; position: relative; padding: 40px 20px; }
|
||||
|
||||
body::before {
|
||||
content: ''; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
|
||||
background: radial-gradient(circle at 10%% 10%%, rgba(99, 102, 241, 0.1), transparent 40%%),
|
||||
radial-gradient(circle at 90%% 90%%, rgba(168, 85, 247, 0.1), transparent 40%%);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.container { max-width: 1200px; margin: 0 auto; animation: fadeIn 0.8s ease-out; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 40px; }
|
||||
h1 { font-size: 2.5rem; font-weight: 600; background: linear-gradient(135deg, var(--primary), var(--secondary)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.header p { color: var(--text-dim); font-size: 1.1rem; margin-top: 8px; }
|
||||
|
||||
.main-card { background: var(--glass); border: 1px solid var(--glass-border); backdrop-filter: blur(20px); border-radius: 24px; overflow: hidden; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
|
||||
|
||||
.toolbar { padding: 24px 32px; border-bottom: 1px solid var(--glass-border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.search-box input { background: var(--bg-accent); border: 1px solid var(--glass-border); color: white; padding: 12px 20px; border-radius: 12px; font-size: 14px; width: 300px; outline: none; transition: border-color 0.3s; }
|
||||
.search-box input:focus { border-color: var(--primary); }
|
||||
|
||||
table { width: 100%%%%; border-collapse: collapse; }
|
||||
th { text-align: left; padding: 16px 32px; font-size: 0.8rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; border-bottom: 1px solid var(--glass-border); }
|
||||
td { padding: 20px 32px; border-bottom: 1px solid var(--glass-border); font-size: 0.95rem; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
|
||||
.badge { padding: 6px 12px; border-radius: 100px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge.approved { background: rgba(16, 185, 129, 0.1); color: var(--success); }
|
||||
.badge.pending { background: rgba(245, 158, 11, 0.1); color: var(--warning); }
|
||||
.badge.rejected { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||
|
||||
.btn { padding: 8px 16px; border-radius: 10px; font-weight: 600; font-size: 0.85rem; border: none; cursor: pointer; transition: 0.3s; text-decoration: none; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: #5a5ce5; transform: translateY(-2px); }
|
||||
.btn-danger { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
|
||||
.btn-danger:hover { background: var(--danger); color: white; }
|
||||
|
||||
.pagination { padding: 24px 32px; display: flex; justify-content: space-between; align-items: center; color: var(--text-dim); font-size: 0.9rem; }
|
||||
.btn-nav { background: var(--bg-accent); color: white; padding: 8px 16px; border-radius: 8px; font-weight: 600; }
|
||||
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
|
||||
#toast-box { position: fixed; top: 20px; right: 20px; background: var(--bg-accent); border: 1px solid var(--glass-border); padding: 12px 24px; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5); transform: translateX(200%%); transition: 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275); z-index: 1000; }
|
||||
#toast-box.show { transform: translateX(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="header">
|
||||
<div>
|
||||
<h1>实名验证管理</h1>
|
||||
<p>集中处理全站用户的身份验证申请</p>
|
||||
</div>
|
||||
<a href="/%%s" class="btn btn-primary" style="background: var(--bg-accent)">返回控制台</a>
|
||||
</header>
|
||||
|
||||
<div class="main-card">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<input type="text" id="keyword" placeholder="搜邮箱或姓名..." onkeypress="if(event.keyCode==13) loadData(1)">
|
||||
</div>
|
||||
<div id="status-label" style="font-weight: 600">正在获取数据...</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户 ID</th>
|
||||
<th>邮箱</th>
|
||||
<th>真实姓名</th>
|
||||
<th>认证状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="table-body">
|
||||
<!-- Rows will be injected here -->
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button class="btn-nav" id="prev-btn" onclick="loadData(state.page - 1)">上一页</button>
|
||||
<div id="page-label">第 1 / 1 页</div>
|
||||
<button class="btn-nav" id="next-btn" onclick="loadData(state.page + 1)">下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="toast-box">操作成功</div>
|
||||
|
||||
<script>
|
||||
const state = { page: 1, lastPage: 1 };
|
||||
const api = "%%s";
|
||||
const reviewApi = "%%s";
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.getElementById('toast-box');
|
||||
t.innerText = msg;
|
||||
t.classList.add('show');
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
async function loadData(page = 1) {
|
||||
state.page = page;
|
||||
const keyword = document.getElementById('keyword').value;
|
||||
try {
|
||||
const res = await fetch(api + '?page=' + page + '&keyword=' + keyword);
|
||||
const data = await res.json();
|
||||
const list = data.data || [];
|
||||
state.lastPage = data.pagination.last_page;
|
||||
|
||||
document.getElementById('page-label').innerText = '第 ' + page + ' / ' + state.lastPage + ' 页';
|
||||
document.getElementById('status-label').innerText = '共计 ' + data.pagination.total + ' 条记录';
|
||||
|
||||
let html = '';
|
||||
list.forEach(item => {
|
||||
const statusLabel = item.status === 'approved' ? '已通过' : (item.status === 'pending' ? '待审核' : '已驳回');
|
||||
html += '<tr>' +
|
||||
'<td><span style="opacity: 0.5">#</span>' + item.user_id + '</td>' +
|
||||
'<td>' + item.user.email + '</td>' +
|
||||
'<td>' + (item.real_name || '-') + '</td>' +
|
||||
'<td><span class="badge ' + item.status + '">' + statusLabel + '</span></td>' +
|
||||
'<td>' +
|
||||
(item.status === 'pending' ? '<button class="btn btn-primary" onclick="review(' + item.id + ', \\'approved\\')">通过</button> ' : '') +
|
||||
'<button class="btn btn-danger" onclick="review(' + item.id + ', \\'rejected\\')">驳回</button>' +
|
||||
'</td>' +
|
||||
'</tr>';
|
||||
});
|
||||
document.getElementById('table-body').innerHTML = html || '<tr><td colspan="5" style="text-align:center; padding:100px; opacity:0.3">暂无数据</td></tr>';
|
||||
|
||||
document.getElementById('prev-btn').disabled = page <= 1;
|
||||
document.getElementById('next-btn').disabled = page >= state.lastPage;
|
||||
} catch (e) { console.error(e); showToast('加载失败'); }
|
||||
}
|
||||
|
||||
async function review(id, status) {
|
||||
try {
|
||||
const res = await fetch(reviewApi + '/' + id, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
});
|
||||
const data = await res.json();
|
||||
showToast(data.message || '操作成功');
|
||||
loadData(state.page);
|
||||
} catch (e) { showToast('操作失败'); }
|
||||
}
|
||||
|
||||
loadData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`, appName, securePath, apiEndpoint, reviewEndpoint)
|
||||
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
// RealNameRecords handles the listing of authentication records.
|
||||
func RealNameRecords(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize := 15
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
var records []model.RealNameAuth
|
||||
var total int64
|
||||
|
||||
query := database.DB.Preload("User").Model(&model.RealNameAuth{})
|
||||
if keyword != "" {
|
||||
query = query.Joins("JOIN v2_user ON v2_user.id = v2_realname_auth.user_id").
|
||||
Where("v2_user.email LIKE ?", "%%"+keyword+"%%")
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
query.Offset((page - 1) * pageSize).Limit(pageSize).Order("created_at DESC").Find(&records)
|
||||
|
||||
lastPage := (total + int64(pageSize) - 1) / int64(pageSize)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": records,
|
||||
"pagination": gin.H{
|
||||
"total": total,
|
||||
"current": page,
|
||||
"last_page": lastPage,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RealNameReview handles approval or rejection of a record.
|
||||
func RealNameReview(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var req struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
var record model.RealNameAuth
|
||||
if err := database.DB.First(&record, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
record.Status = req.Status
|
||||
record.ReviewedAt = time.Now().Unix()
|
||||
database.DB.Save(&record)
|
||||
|
||||
// Sync User Expiration if approved
|
||||
if req.Status == "approved" {
|
||||
// Set a long expiration date (e.g., 2099-12-31)
|
||||
expiry := time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC).Unix()
|
||||
database.DB.Model(&model.User{}).Where("id = ?", record.UserID).Update("expired_at", expiry)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "审核操作成功"})
|
||||
}
|
||||
71
internal/handler/subscribe_handler.go
Normal file
71
internal/handler/subscribe_handler.go
Normal file
@@ -0,0 +1,71 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/protocol"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Subscribe(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
if token == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "缺少Token"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "无效的Token"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.Banned {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "账号已被封禁"})
|
||||
return
|
||||
}
|
||||
|
||||
var servers []model.Server
|
||||
query := database.DB.Where("`show` = ?", 1).Order("sort ASC")
|
||||
if err := query.Find(&servers).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "获取节点失败"})
|
||||
return
|
||||
}
|
||||
|
||||
ua := c.GetHeader("User-Agent")
|
||||
flag := c.Query("flag")
|
||||
|
||||
if strings.Contains(ua, "Clash") || flag == "clash" {
|
||||
config, _ := protocol.GenerateClash(servers, user)
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(ua, "sing-box") || flag == "sing-box" {
|
||||
config, _ := protocol.GenerateSingBox(servers, user)
|
||||
c.Header("Content-Type", "application/json; charset=utf-8")
|
||||
c.String(http.StatusOK, config)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: VMess/SS link format (Base64)
|
||||
var links []string
|
||||
for _, s := range servers {
|
||||
// Mocked link for now
|
||||
link := fmt.Sprintf("vmess://%s", s.Name)
|
||||
links = append(links, link)
|
||||
}
|
||||
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n")))
|
||||
c.Header("Content-Type", "text/plain; charset=utf-8")
|
||||
c.Header("Subscription-Userinfo", fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", user.U, user.D, user.TransferEnable, user.ExpiredAt))
|
||||
c.String(http.StatusOK, encoded)
|
||||
}
|
||||
259
internal/handler/user_api.go
Normal file
259
internal/handler/user_api.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func UserInfo(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"email": user.Email,
|
||||
"transfer_enable": user.TransferEnable,
|
||||
"last_login_at": user.LastLoginAt,
|
||||
"created_at": user.CreatedAt,
|
||||
"banned": user.Banned,
|
||||
"remind_expire": user.RemindExpire,
|
||||
"remind_traffic": user.RemindTraffic,
|
||||
"expired_at": user.ExpiredAt,
|
||||
"balance": user.Balance,
|
||||
"commission_balance": user.CommissionBalance,
|
||||
"plan_id": user.PlanID,
|
||||
"discount": user.Discount,
|
||||
"commission_rate": user.CommissionRate,
|
||||
"telegram_id": user.TelegramID,
|
||||
"uuid": user.UUID,
|
||||
"avatar_url": "https://cdn.v2ex.com/gravatar/" + fmt.Sprintf("%x", md5.Sum([]byte(strings.ToLower(user.Email)))) + "?s=64&d=identicon",
|
||||
})
|
||||
}
|
||||
|
||||
func UserGetStat(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var pendingOrders int64
|
||||
var openTickets int64
|
||||
var invitedUsers int64
|
||||
|
||||
database.DB.Model(&model.Order{}).Where("status = ? AND user_id = ?", 0, user.ID).Count(&pendingOrders)
|
||||
database.DB.Model(&model.Ticket{}).Where("status = ? AND user_id = ?", 0, user.ID).Count(&openTickets)
|
||||
database.DB.Model(&model.User{}).Where("invite_user_id = ?", user.ID).Count(&invitedUsers)
|
||||
|
||||
Success(c, []int64{pendingOrders, openTickets, invitedUsers})
|
||||
}
|
||||
|
||||
func UserGetSubscribe(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if user.PlanID != nil {
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err == nil {
|
||||
user.Plan = &plan
|
||||
}
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(service.GetAppURL(), "/")
|
||||
if baseURL == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = scheme + "://" + c.Request.Host
|
||||
}
|
||||
|
||||
Success(c, gin.H{
|
||||
"plan_id": user.PlanID,
|
||||
"token": user.Token,
|
||||
"expired_at": user.ExpiredAt,
|
||||
"u": user.U,
|
||||
"d": user.D,
|
||||
"transfer_enable": user.TransferEnable,
|
||||
"email": user.Email,
|
||||
"uuid": user.UUID,
|
||||
"device_limit": user.DeviceLimit,
|
||||
"speed_limit": user.SpeedLimit,
|
||||
"next_reset_at": user.NextResetAt,
|
||||
"plan": user.Plan,
|
||||
"subscribe_url": strings.TrimRight(baseURL, "/") + "/api/v1/client/subscribe?token=" + user.Token,
|
||||
"reset_day": nil,
|
||||
})
|
||||
}
|
||||
|
||||
func UserCheckLogin(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Success(c, gin.H{"is_login": false})
|
||||
return
|
||||
}
|
||||
Success(c, gin.H{
|
||||
"is_login": true,
|
||||
"is_admin": user.IsAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
func UserResetSecurity(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
newUUID := uuid.New().String()
|
||||
newToken := fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+user.Email)))[:16]
|
||||
if err := database.DB.Model(&model.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Updates(map[string]any{"uuid": newUUID, "token": newToken, "updated_at": time.Now().Unix()}).Error; err != nil {
|
||||
Fail(c, 500, "reset failed")
|
||||
return
|
||||
}
|
||||
|
||||
baseURL := strings.TrimRight(service.GetAppURL(), "/")
|
||||
if baseURL == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
baseURL = scheme + "://" + c.Request.Host
|
||||
}
|
||||
Success(c, strings.TrimRight(baseURL, "/")+"/api/v1/client/subscribe?token="+newToken)
|
||||
}
|
||||
|
||||
func UserUpdate(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
RemindExpire *int `json:"remind_expire"`
|
||||
RemindTraffic *int `json:"remind_traffic"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{"updated_at": time.Now().Unix()}
|
||||
if req.RemindExpire != nil {
|
||||
updates["remind_expire"] = *req.RemindExpire
|
||||
}
|
||||
if req.RemindTraffic != nil {
|
||||
updates["remind_traffic"] = *req.RemindTraffic
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil {
|
||||
Fail(c, 500, "save failed")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func UserChangePassword(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CheckPassword(req.OldPassword, user.Password, user.PasswordAlgo, user.PasswordSalt) {
|
||||
Fail(c, 400, "the old password is wrong")
|
||||
return
|
||||
}
|
||||
|
||||
hashed, err := utils.HashPassword(req.NewPassword)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to hash password")
|
||||
return
|
||||
}
|
||||
|
||||
if err := database.DB.Model(&model.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Updates(map[string]any{
|
||||
"password": hashed,
|
||||
"password_algo": nil,
|
||||
"password_salt": nil,
|
||||
"updated_at": time.Now().Unix(),
|
||||
}).Error; err != nil {
|
||||
Fail(c, 500, "save failed")
|
||||
return
|
||||
}
|
||||
|
||||
Success(c, true)
|
||||
}
|
||||
|
||||
func UserServerFetch(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
servers, err := service.AvailableServersForUser(user)
|
||||
if err != nil {
|
||||
Fail(c, 500, "failed to fetch servers")
|
||||
return
|
||||
}
|
||||
Success(c, servers)
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (*model.User, bool) {
|
||||
if value, exists := c.Get("user"); exists {
|
||||
if user, ok := value.(*model.User); ok {
|
||||
return user, true
|
||||
}
|
||||
}
|
||||
|
||||
userIDValue, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
userID, ok := userIDValue.(int)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.First(&user, userID).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if service.IsPluginEnabled(service.PluginUserAddIPv6) && !strings.Contains(user.Email, "-ipv6@") && user.PlanID != nil {
|
||||
service.SyncIPv6ShadowAccount(&user)
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
return &user, true
|
||||
}
|
||||
1067
internal/handler/user_extra_api.go
Normal file
1067
internal/handler/user_extra_api.go
Normal file
File diff suppressed because it is too large
Load Diff
25
internal/handler/user_handler.go
Normal file
25
internal/handler/user_handler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
//go:build ignore
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserInfo(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Preload("Plan").First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "用户不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"data": user,
|
||||
})
|
||||
}
|
||||
313
internal/handler/user_support_api.go
Normal file
313
internal/handler/user_support_api.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func UserKnowledgeFetch(c *gin.Context) {
|
||||
language := strings.TrimSpace(c.DefaultQuery("language", "zh-CN"))
|
||||
query := database.DB.Model(&model.Knowledge{}).Where("`show` = ?", 1).Order("sort ASC, id ASC")
|
||||
if language != "" {
|
||||
query = query.Where("language = ? OR language = ''", language)
|
||||
}
|
||||
|
||||
var articles []model.Knowledge
|
||||
if err := query.Find(&articles).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch knowledge articles")
|
||||
return
|
||||
}
|
||||
Success(c, articles)
|
||||
}
|
||||
|
||||
func UserKnowledgeCategories(c *gin.Context) {
|
||||
var categories []string
|
||||
if err := database.DB.Model(&model.Knowledge{}).
|
||||
Where("`show` = ?", 1).
|
||||
Distinct().
|
||||
Order("category ASC").
|
||||
Pluck("category", &categories).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch knowledge categories")
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(categories))
|
||||
for _, category := range categories {
|
||||
category = strings.TrimSpace(category)
|
||||
if category != "" && !slices.Contains(filtered, category) {
|
||||
filtered = append(filtered, category)
|
||||
}
|
||||
}
|
||||
Success(c, filtered)
|
||||
}
|
||||
|
||||
func UserTicketFetch(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
if ticketID := strings.TrimSpace(c.Query("id")); ticketID != "" {
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", ticketID, user.ID).First(&ticket).Error; err != nil {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
var messages []model.TicketMessage
|
||||
_ = database.DB.Where("ticket_id = ?", ticket.ID).Order("id ASC").Find(&messages).Error
|
||||
|
||||
payload := gin.H{
|
||||
"id": ticket.ID,
|
||||
"user_id": ticket.UserID,
|
||||
"subject": ticket.Subject,
|
||||
"level": ticket.Level,
|
||||
"status": ticket.Status,
|
||||
"reply_status": ticket.ReplyStatus,
|
||||
"created_at": ticket.CreatedAt,
|
||||
"updated_at": ticket.UpdatedAt,
|
||||
"message": buildTicketMessages(messages, user.ID),
|
||||
}
|
||||
Success(c, payload)
|
||||
return
|
||||
}
|
||||
|
||||
var tickets []model.Ticket
|
||||
if err := database.DB.Where("user_id = ?", user.ID).Order("updated_at DESC, id DESC").Find(&tickets).Error; err != nil {
|
||||
Fail(c, 500, "failed to fetch tickets")
|
||||
return
|
||||
}
|
||||
Success(c, tickets)
|
||||
}
|
||||
|
||||
func UserTicketSave(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Subject string `json:"subject" binding:"required"`
|
||||
Level int `json:"level"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid ticket payload")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
ticket := model.Ticket{
|
||||
UserID: user.ID,
|
||||
Subject: strings.TrimSpace(req.Subject),
|
||||
Level: req.Level,
|
||||
Status: 0,
|
||||
ReplyStatus: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := database.DB.Create(&ticket).Error; err != nil {
|
||||
Fail(c, 500, "failed to create ticket")
|
||||
return
|
||||
}
|
||||
|
||||
message := model.TicketMessage{
|
||||
UserID: user.ID,
|
||||
TicketID: ticket.ID,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := database.DB.Create(&message).Error; err != nil {
|
||||
Fail(c, 500, "failed to save ticket message")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "ticket created", true)
|
||||
}
|
||||
|
||||
func UserTicketReply(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
Message string `json:"message" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "invalid ticket reply payload")
|
||||
return
|
||||
}
|
||||
|
||||
var ticket model.Ticket
|
||||
if err := database.DB.Where("id = ? AND user_id = ?", req.ID, user.ID).First(&ticket).Error; err != nil {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
if ticket.Status != 0 {
|
||||
Fail(c, 400, "ticket is closed")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
reply := model.TicketMessage{
|
||||
UserID: user.ID,
|
||||
TicketID: ticket.ID,
|
||||
Message: strings.TrimSpace(req.Message),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := database.DB.Create(&reply).Error; err != nil {
|
||||
Fail(c, 500, "failed to save ticket reply")
|
||||
return
|
||||
}
|
||||
|
||||
_ = database.DB.Model(&model.Ticket{}).
|
||||
Where("id = ?", ticket.ID).
|
||||
Updates(map[string]any{"reply_status": 1, "updated_at": now}).Error
|
||||
|
||||
SuccessMessage(c, "ticket replied", true)
|
||||
}
|
||||
|
||||
func UserTicketClose(c *gin.Context) {
|
||||
updateTicketStatus(c, 1, "ticket closed")
|
||||
}
|
||||
|
||||
func UserTicketWithdraw(c *gin.Context) {
|
||||
updateTicketStatus(c, 1, "ticket withdrawn")
|
||||
}
|
||||
|
||||
func UserGetActiveSession(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
authToken, _ := c.Get("auth_token")
|
||||
currentToken, _ := authToken.(string)
|
||||
sessions := service.GetUserSessions(user.ID, currentToken)
|
||||
payload := make([]gin.H, 0, len(sessions))
|
||||
currentSessionID := currentSessionID(c)
|
||||
|
||||
for _, session := range sessions {
|
||||
payload = append(payload, gin.H{
|
||||
"id": session.ID,
|
||||
"name": session.Name,
|
||||
"user_agent": session.UserAgent,
|
||||
"ip": firstString(session.IP, "-"),
|
||||
"created_at": session.CreatedAt,
|
||||
"last_used_at": session.LastUsedAt,
|
||||
"expires_at": session.ExpiresAt,
|
||||
"is_current": session.ID == currentSessionID,
|
||||
})
|
||||
}
|
||||
|
||||
Success(c, payload)
|
||||
}
|
||||
|
||||
func UserRemoveActiveSession(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
SessionID string `json:"session_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "session_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
if !service.RemoveUserSession(user.ID, req.SessionID) {
|
||||
Fail(c, 404, "session not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, "session removed", true)
|
||||
}
|
||||
|
||||
func updateTicketStatus(c *gin.Context, status int, message string) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
Fail(c, http.StatusUnauthorized, "user not found")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ID int `json:"id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
Fail(c, 400, "ticket id is required")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
result := database.DB.Model(&model.Ticket{}).
|
||||
Where("id = ? AND user_id = ?", req.ID, user.ID).
|
||||
Updates(map[string]any{
|
||||
"status": status,
|
||||
"updated_at": now,
|
||||
})
|
||||
if result.Error != nil {
|
||||
Fail(c, 500, "failed to update ticket")
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
Fail(c, 404, "ticket not found")
|
||||
return
|
||||
}
|
||||
|
||||
SuccessMessage(c, message, true)
|
||||
}
|
||||
|
||||
func buildTicketMessages(messages []model.TicketMessage, currentUserID int) []gin.H {
|
||||
payload := make([]gin.H, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
payload = append(payload, gin.H{
|
||||
"id": message.ID,
|
||||
"user_id": message.UserID,
|
||||
"message": message.Message,
|
||||
"created_at": message.CreatedAt,
|
||||
"updated_at": message.UpdatedAt,
|
||||
"is_me": message.UserID == currentUserID,
|
||||
})
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func currentSessionID(c *gin.Context) string {
|
||||
value, exists := c.Get("session")
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
session, ok := value.(service.SessionRecord)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return session.ID
|
||||
}
|
||||
|
||||
func firstString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
104
internal/handler/web_pages.go
Normal file
104
internal/handler/web_pages.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type userThemeViewData struct {
|
||||
Title string
|
||||
Description string
|
||||
Version string
|
||||
Theme string
|
||||
Logo string
|
||||
AssetsPath string
|
||||
CustomHTML template.HTML
|
||||
ThemeConfigJSON template.JS
|
||||
}
|
||||
|
||||
type adminAppViewData struct {
|
||||
Title string
|
||||
ConfigJSON template.JS
|
||||
}
|
||||
|
||||
func UserThemePage(c *gin.Context) {
|
||||
config := map[string]any{
|
||||
"accent": service.MustGetString("nebula_theme_color", "aurora"),
|
||||
"slogan": service.MustGetString("nebula_hero_slogan", "One control center for login, subscriptions, sessions, and device visibility."),
|
||||
"backgroundUrl": service.MustGetString("nebula_background_url", ""),
|
||||
"metricsBaseUrl": service.MustGetString("nebula_metrics_base_url", ""),
|
||||
"defaultThemeMode": service.MustGetString("nebula_default_theme_mode", "system"),
|
||||
"lightLogoUrl": service.MustGetString("nebula_light_logo_url", ""),
|
||||
"darkLogoUrl": service.MustGetString("nebula_dark_logo_url", ""),
|
||||
"welcomeTarget": service.MustGetString("nebula_welcome_target", service.MustGetString("app_name", "XBoard")),
|
||||
"registerTitle": service.MustGetString("nebula_register_title", "Create your access."),
|
||||
"icpNo": service.MustGetString("icp_no", ""),
|
||||
"psbNo": service.MustGetString("psb_no", ""),
|
||||
"isRegisterEnabled": !service.MustGetBool("stop_register", false),
|
||||
}
|
||||
|
||||
payload := userThemeViewData{
|
||||
Title: service.MustGetString("app_name", "XBoard"),
|
||||
Description: service.MustGetString("app_description", "Go rebuilt control panel"),
|
||||
Version: service.MustGetString("app_version", "2.0.0"),
|
||||
Theme: "Nebula",
|
||||
Logo: service.MustGetString("logo", ""),
|
||||
AssetsPath: "/theme/Nebula/assets",
|
||||
CustomHTML: template.HTML(service.MustGetString("nebula_custom_html", "")),
|
||||
ThemeConfigJSON: mustJSON(config),
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "user_nebula.html"), payload)
|
||||
}
|
||||
|
||||
func AdminAppPage(c *gin.Context) {
|
||||
securePath := service.GetAdminSecurePath()
|
||||
config := map[string]any{
|
||||
"title": service.MustGetString("app_name", "XBoard") + " Admin",
|
||||
"securePath": securePath,
|
||||
"api": map[string]string{
|
||||
"adminConfig": "/api/v2/" + securePath + "/config/fetch",
|
||||
"systemStatus": "/api/v2/" + securePath + "/system/getSystemStatus",
|
||||
"plugins": "/api/v2/" + securePath + "/plugin/getPlugins",
|
||||
"integration": "/api/v2/" + securePath + "/plugin/integration-status",
|
||||
"realnameBase": "/api/v1/" + securePath + "/realname",
|
||||
"onlineDevices": "/api/v1/" + securePath + "/user-online-devices/users",
|
||||
},
|
||||
}
|
||||
payload := adminAppViewData{
|
||||
Title: config["title"].(string),
|
||||
ConfigJSON: mustJSON(config),
|
||||
}
|
||||
|
||||
renderPageTemplate(c, filepath.Join("frontend", "templates", "admin_app.html"), payload)
|
||||
}
|
||||
|
||||
func renderPageTemplate(c *gin.Context, templatePath string, data any) {
|
||||
tpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "template parse error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, data); err != nil {
|
||||
c.String(http.StatusInternalServerError, "template render error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||
}
|
||||
|
||||
func mustJSON(value any) template.JS {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return template.JS("{}")
|
||||
}
|
||||
return template.JS(payload)
|
||||
}
|
||||
52
internal/middleware/auth.go
Normal file
52
internal/middleware/auth.go
Normal file
@@ -0,0 +1,52 @@
|
||||
//go:build ignore
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "未登录"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if !(len(parts) == 2 && parts[0] == "Bearer") {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "无效的认证格式"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "登录已过期"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isAdmin, exists := c.Get("is_admin")
|
||||
if !exists || !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "权限不足"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
86
internal/middleware/auth_v2.go
Normal file
86
internal/middleware/auth_v2.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
"xboard-go/internal/service"
|
||||
"xboard-go/pkg/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Auth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "unauthorized"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid authorization header"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.VerifyToken(parts[1])
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "token expired or invalid"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if service.IsSessionTokenRevoked(parts[1]) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "session has been revoked"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("is_admin", claims.IsAdmin)
|
||||
c.Set("auth_token", parts[1])
|
||||
c.Set("session", service.TrackSession(claims.UserID, parts[1], c.ClientIP(), c.GetHeader("User-Agent")))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func AdminAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isAdmin, exists := c.Get("is_admin")
|
||||
if !exists || !isAdmin.(bool) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "admin access required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ClientAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Param("token")
|
||||
}
|
||||
if token == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "token is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("token = ?", token).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "invalid token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user", &user)
|
||||
c.Set("user_id", user.ID)
|
||||
c.Set("is_admin", user.IsAdmin)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
50
internal/middleware/node_auth.go
Normal file
50
internal/middleware/node_auth.go
Normal file
@@ -0,0 +1,50 @@
|
||||
//go:build ignore
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
nodeID := c.Query("node_id")
|
||||
nodeType := c.Query("node_type")
|
||||
|
||||
if token == "" || nodeID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "缺少认证信息"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Check server_token from settings
|
||||
var setting model.Setting
|
||||
if err := database.DB.Where("name = ?", "server_token").First(&setting).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "系统配置错误"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if token != setting.Value {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "无效的Token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// Get node info
|
||||
var node model.Server
|
||||
if err := database.DB.Where("id = ? AND type = ?", nodeID, nodeType).First(&node).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "节点不存在"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("node", &node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
51
internal/middleware/node_auth_v2.go
Normal file
51
internal/middleware/node_auth_v2.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"xboard-go/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func NodeAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := c.Query("token")
|
||||
nodeID := c.Query("node_id")
|
||||
nodeType := service.NormalizeServerType(c.Query("node_type"))
|
||||
|
||||
if token == "" || nodeID == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "missing node credentials"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if !service.IsValidServerType(nodeType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "invalid node type"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
serverToken := service.MustGetString("server_token", "")
|
||||
if serverToken == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "server_token is not configured"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if token != serverToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "invalid server token"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
node, err := service.FindServer(nodeID, nodeType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "server not found"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("node", node)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
16
internal/model/commission_log.go
Normal file
16
internal/model/commission_log.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
type CommissionLog struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID *int `gorm:"column:user_id" json:"user_id"`
|
||||
InviteUserID int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
TradeNo string `gorm:"column:trade_no" json:"trade_no"`
|
||||
OrderAmount int64 `gorm:"column:order_amount" json:"order_amount"`
|
||||
GetAmount int64 `gorm:"column:get_amount" json:"get_amount"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CommissionLog) TableName() string {
|
||||
return "v2_commission_log"
|
||||
}
|
||||
22
internal/model/coupon.go
Normal file
22
internal/model/coupon.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
type Coupon struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Type int `gorm:"column:type" json:"type"`
|
||||
Value int64 `gorm:"column:value" json:"value"`
|
||||
LimitPlanIDs *string `gorm:"column:limit_plan_ids" json:"limit_plan_ids"`
|
||||
LimitPeriod *string `gorm:"column:limit_period" json:"limit_period"`
|
||||
LimitUse *int `gorm:"column:limit_use" json:"limit_use"`
|
||||
LimitUseWithUser *int `gorm:"column:limit_use_with_user" json:"limit_use_with_user"`
|
||||
StartedAt int64 `gorm:"column:started_at" json:"started_at"`
|
||||
EndedAt int64 `gorm:"column:ended_at" json:"ended_at"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Coupon) TableName() string {
|
||||
return "v2_coupon"
|
||||
}
|
||||
15
internal/model/invite_code.go
Normal file
15
internal/model/invite_code.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type InviteCode struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Status bool `gorm:"column:status" json:"status"`
|
||||
PV int `gorm:"column:pv" json:"pv"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (InviteCode) TableName() string {
|
||||
return "v2_invite_code"
|
||||
}
|
||||
17
internal/model/knowledge.go
Normal file
17
internal/model/knowledge.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
type Knowledge struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Language string `gorm:"column:language" json:"language"`
|
||||
Category string `gorm:"column:category" json:"category"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Body string `gorm:"column:body" json:"body"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Knowledge) TableName() string {
|
||||
return "v2_knowledge"
|
||||
}
|
||||
18
internal/model/notice.go
Normal file
18
internal/model/notice.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
type Notice struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Title string `gorm:"column:title" json:"title"`
|
||||
Content string `gorm:"column:content" json:"content"`
|
||||
ImgURL *string `gorm:"column:img_url" json:"img_url"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Popup bool `gorm:"column:popup" json:"popup"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Notice) TableName() string {
|
||||
return "v2_notice"
|
||||
}
|
||||
36
internal/model/order.go
Normal file
36
internal/model/order.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
type Order struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
PlanID *int `gorm:"column:plan_id" json:"plan_id"`
|
||||
PaymentID *int `gorm:"column:payment_id" json:"payment_id"`
|
||||
Period string `gorm:"column:period" json:"period"`
|
||||
TradeNo string `gorm:"column:trade_no" json:"trade_no"`
|
||||
TotalAmount int64 `gorm:"column:total_amount" json:"total_amount"`
|
||||
HandlingAmount *int64 `gorm:"column:handling_amount" json:"handling_amount"`
|
||||
BalanceAmount *int64 `gorm:"column:balance_amount" json:"balance_amount"`
|
||||
RefundAmount *int64 `gorm:"column:refund_amount" json:"refund_amount"`
|
||||
SurplusAmount *int64 `gorm:"column:surplus_amount" json:"surplus_amount"`
|
||||
Type int `gorm:"column:type" json:"type"`
|
||||
Status int `gorm:"column:status" json:"status"`
|
||||
SurplusOrderIDs *string `gorm:"column:surplus_order_ids" json:"surplus_order_ids"`
|
||||
CouponID *int `gorm:"column:coupon_id" json:"coupon_id"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
CommissionStatus *int `gorm:"column:commission_status" json:"commission_status"`
|
||||
InviteUserID *int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
ActualCommissionBalance *int64 `gorm:"column:actual_commission_balance" json:"actual_commission_balance"`
|
||||
CommissionRate *int `gorm:"column:commission_rate" json:"commission_rate"`
|
||||
CommissionAutoCheck *int `gorm:"column:commission_auto_check" json:"commission_auto_check"`
|
||||
CommissionBalance *int64 `gorm:"column:commission_balance" json:"commission_balance"`
|
||||
DiscountAmount *int64 `gorm:"column:discount_amount" json:"discount_amount"`
|
||||
PaidAt *int64 `gorm:"column:paid_at" json:"paid_at"`
|
||||
CallbackNo *string `gorm:"column:callback_no" json:"callback_no"`
|
||||
Plan *Plan `gorm:"foreignKey:PlanID" json:"plan,omitempty"`
|
||||
Payment *Payment `gorm:"foreignKey:PaymentID" json:"payment,omitempty"`
|
||||
}
|
||||
|
||||
func (Order) TableName() string {
|
||||
return "v2_order"
|
||||
}
|
||||
19
internal/model/payment.go
Normal file
19
internal/model/payment.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
type Payment struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Payment string `gorm:"column:payment" json:"payment"`
|
||||
Icon *string `gorm:"column:icon" json:"icon"`
|
||||
Config *string `gorm:"column:config" json:"config"`
|
||||
HandlingFeeFixed *int64 `gorm:"column:handling_fee_fixed" json:"handling_fee_fixed"`
|
||||
HandlingFeePercent *int64 `gorm:"column:handling_fee_percent" json:"handling_fee_percent"`
|
||||
Enable bool `gorm:"column:enable" json:"enable"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Payment) TableName() string {
|
||||
return "v2_payment"
|
||||
}
|
||||
63
internal/model/plan_server.go
Normal file
63
internal/model/plan_server.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
GroupID *int `gorm:"column:group_id" json:"group_id"`
|
||||
TransferEnable *int `gorm:"column:transfer_enable" json:"transfer_enable"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Reference *string `gorm:"column:reference" json:"reference"`
|
||||
SpeedLimit *int `gorm:"column:speed_limit" json:"speed_limit"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
Renew bool `gorm:"column:renew;default:1" json:"renew"`
|
||||
Content *string `gorm:"column:content" json:"content"`
|
||||
ResetTrafficMethod *int `gorm:"column:reset_traffic_method;default:0" json:"reset_traffic_method"`
|
||||
CapacityLimit *int `gorm:"column:capacity_limit;default:0" json:"capacity_limit"`
|
||||
Prices *string `gorm:"column:prices" json:"prices"`
|
||||
Sell bool `gorm:"column:sell" json:"sell"`
|
||||
DeviceLimit *int `gorm:"column:device_limit" json:"device_limit"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Plan) TableName() string {
|
||||
return "v2_plan"
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
Code *string `gorm:"column:code" json:"code"`
|
||||
ParentID *int `gorm:"column:parent_id" json:"parent_id"`
|
||||
GroupIDs *string `gorm:"column:group_ids" json:"group_ids"`
|
||||
RouteIDs *string `gorm:"column:route_ids" json:"route_ids"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Rate float32 `gorm:"column:rate" json:"rate"`
|
||||
TransferEnable *int64 `gorm:"column:transfer_enable" json:"transfer_enable"`
|
||||
U int64 `gorm:"column:u;default:0" json:"u"`
|
||||
D int64 `gorm:"column:d;default:0" json:"d"`
|
||||
Tags *string `gorm:"column:tags" json:"tags"`
|
||||
Host string `gorm:"column:host" json:"host"`
|
||||
Port string `gorm:"column:port" json:"port"`
|
||||
ServerPort int `gorm:"column:server_port" json:"server_port"`
|
||||
ProtocolSettings *string `gorm:"column:protocol_settings" json:"protocol_settings"`
|
||||
CustomOutbounds *string `gorm:"column:custom_outbounds" json:"custom_outbounds"`
|
||||
CustomRoutes *string `gorm:"column:custom_routes" json:"custom_routes"`
|
||||
CertConfig *string `gorm:"column:cert_config" json:"cert_config"`
|
||||
Show bool `gorm:"column:show" json:"show"`
|
||||
Sort *int `gorm:"column:sort" json:"sort"`
|
||||
RateTimeEnable bool `gorm:"column:rate_time_enable" json:"rate_time_enable"`
|
||||
RateTimeRanges *string `gorm:"column:rate_time_ranges" json:"rate_time_ranges"`
|
||||
IPv6Password *string `gorm:"column:ipv6_password" json:"ipv6_password"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Server) TableName() string {
|
||||
return "v2_server"
|
||||
}
|
||||
21
internal/model/plugin.go
Normal file
21
internal/model/plugin.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
type Plugin struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Code string `gorm:"column:code" json:"code"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Description *string `gorm:"column:description" json:"description"`
|
||||
Version *string `gorm:"column:version" json:"version"`
|
||||
Author *string `gorm:"column:author" json:"author"`
|
||||
URL *string `gorm:"column:url" json:"url"`
|
||||
Email *string `gorm:"column:email" json:"email"`
|
||||
License *string `gorm:"column:license" json:"license"`
|
||||
Requires *string `gorm:"column:requires" json:"requires"`
|
||||
Config *string `gorm:"column:config" json:"config"`
|
||||
Type *string `gorm:"column:type" json:"type"`
|
||||
IsEnabled bool `gorm:"column:is_enabled" json:"is_enabled"`
|
||||
}
|
||||
|
||||
func (Plugin) TableName() string {
|
||||
return "v2_plugins"
|
||||
}
|
||||
21
internal/model/realname.go
Normal file
21
internal/model/realname.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
type RealNameAuth struct {
|
||||
ID uint64 `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID uint64 `gorm:"column:user_id;uniqueIndex" json:"user_id"`
|
||||
RealName string `gorm:"column:real_name" json:"real_name"`
|
||||
IdentityMasked string `gorm:"column:identity_masked" json:"identity_masked"`
|
||||
IdentityEncrypted string `gorm:"column:identity_encrypted" json:"-"`
|
||||
Status string `gorm:"column:status;index;default:pending" json:"status"`
|
||||
SubmittedAt int64 `gorm:"column:submitted_at" json:"submitted_at"`
|
||||
ReviewedAt int64 `gorm:"column:reviewed_at" json:"reviewed_at"`
|
||||
RejectReason string `gorm:"column:reject_reason" json:"reject_reason"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||
}
|
||||
|
||||
func (RealNameAuth) TableName() string {
|
||||
return "v2_realname_auth"
|
||||
}
|
||||
12
internal/model/server_group.go
Normal file
12
internal/model/server_group.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
type ServerGroup struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ServerGroup) TableName() string {
|
||||
return "v2_server_group"
|
||||
}
|
||||
15
internal/model/server_route.go
Normal file
15
internal/model/server_route.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type ServerRoute struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Remarks *string `gorm:"column:remarks" json:"remarks"`
|
||||
Match *string `gorm:"column:match" json:"match"`
|
||||
Action *string `gorm:"column:action" json:"action"`
|
||||
ActionValue *string `gorm:"column:action_value" json:"action_value"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (ServerRoute) TableName() string {
|
||||
return "v2_server_route"
|
||||
}
|
||||
17
internal/model/setting.go
Normal file
17
internal/model/setting.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
type Setting struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
Group *string `gorm:"column:group" json:"group"`
|
||||
Type *string `gorm:"column:type" json:"type"`
|
||||
Name string `gorm:"column:name;index" json:"name"`
|
||||
Value string `gorm:"column:value" json:"value"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt *time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Setting) TableName() string {
|
||||
return "v2_settings"
|
||||
}
|
||||
15
internal/model/stat_user.go
Normal file
15
internal/model/stat_user.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
type StatUser struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
U int64 `gorm:"column:u" json:"u"`
|
||||
D int64 `gorm:"column:d" json:"d"`
|
||||
RecordAt int64 `gorm:"column:record_at" json:"record_at"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (StatUser) TableName() string {
|
||||
return "v2_stat_user"
|
||||
}
|
||||
16
internal/model/ticket.go
Normal file
16
internal/model/ticket.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package model
|
||||
|
||||
type Ticket struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
Subject string `gorm:"column:subject" json:"subject"`
|
||||
Level int `gorm:"column:level" json:"level"`
|
||||
Status int `gorm:"column:status" json:"status"`
|
||||
ReplyStatus int `gorm:"column:reply_status" json:"reply_status"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Ticket) TableName() string {
|
||||
return "v2_ticket"
|
||||
}
|
||||
14
internal/model/ticket_message.go
Normal file
14
internal/model/ticket_message.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package model
|
||||
|
||||
type TicketMessage struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
UserID int `gorm:"column:user_id" json:"user_id"`
|
||||
TicketID int `gorm:"column:ticket_id" json:"ticket_id"`
|
||||
Message string `gorm:"column:message" json:"message"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (TicketMessage) TableName() string {
|
||||
return "v2_ticket_message"
|
||||
}
|
||||
52
internal/model/user.go
Normal file
52
internal/model/user.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int `gorm:"primaryKey;column:id" json:"id"`
|
||||
ParentID *int `gorm:"column:parent_id" json:"parent_id"`
|
||||
InviteUserID *int `gorm:"column:invite_user_id" json:"invite_user_id"`
|
||||
TelegramID *int64 `gorm:"column:telegram_id" json:"telegram_id"`
|
||||
Email string `gorm:"column:email;unique" json:"email"`
|
||||
Password string `gorm:"column:password" json:"-"`
|
||||
PasswordAlgo *string `gorm:"column:password_algo" json:"-"`
|
||||
PasswordSalt *string `gorm:"column:password_salt" json:"-"`
|
||||
Balance uint64 `gorm:"column:balance;default:0" json:"balance"`
|
||||
Discount *int `gorm:"column:discount" json:"discount"`
|
||||
CommissionType int `gorm:"column:commission_type;default:0" json:"commission_type"`
|
||||
CommissionRate *int `gorm:"column:commission_rate" json:"commission_rate"`
|
||||
CommissionBalance uint64 `gorm:"column:commission_balance;default:0" json:"commission_balance"`
|
||||
T uint64 `gorm:"column:t;default:0" json:"t"`
|
||||
U uint64 `gorm:"column:u;default:0" json:"u"`
|
||||
D uint64 `gorm:"column:d;default:0" json:"d"`
|
||||
TransferEnable uint64 `gorm:"column:transfer_enable;default:0" json:"transfer_enable"`
|
||||
Banned bool `gorm:"column:banned" json:"banned"`
|
||||
IsAdmin bool `gorm:"column:is_admin" json:"is_admin"`
|
||||
IsStaff bool `gorm:"column:is_staff" json:"is_staff"`
|
||||
LastLoginAt *int64 `gorm:"column:last_login_at" json:"last_login_at"`
|
||||
LastLoginIP *int64 `gorm:"column:last_login_ip" json:"last_login_ip"`
|
||||
UUID string `gorm:"column:uuid" json:"uuid"`
|
||||
GroupID *int `gorm:"column:group_id" json:"group_id"`
|
||||
PlanID *int `gorm:"column:plan_id" json:"plan_id"`
|
||||
Plan *Plan `gorm:"foreignKey:PlanID" json:"plan"`
|
||||
SpeedLimit *int `gorm:"column:speed_limit" json:"speed_limit"`
|
||||
RemindExpire int `gorm:"column:remind_expire;default:1" json:"remind_expire"`
|
||||
RemindTraffic int `gorm:"column:remind_traffic;default:1" json:"remind_traffic"`
|
||||
Token string `gorm:"column:token" json:"token"`
|
||||
ExpiredAt *int64 `gorm:"column:expired_at" json:"expired_at"`
|
||||
Remarks *string `gorm:"column:remarks" json:"remarks"`
|
||||
CreatedAt int64 `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt int64 `gorm:"column:updated_at" json:"updated_at"`
|
||||
DeviceLimit *int `gorm:"column:device_limit" json:"device_limit"`
|
||||
OnlineCount *int `gorm:"column:online_count" json:"online_count"`
|
||||
LastOnlineAt *time.Time `gorm:"column:last_online_at" json:"last_online_at"`
|
||||
NextResetAt *int64 `gorm:"column:next_reset_at" json:"next_reset_at"`
|
||||
LastResetAt *int64 `gorm:"column:last_reset_at" json:"last_reset_at"`
|
||||
ResetCount int `gorm:"column:reset_count;default:0" json:"reset_count"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "v2_user"
|
||||
}
|
||||
32
internal/protocol/clash.go
Normal file
32
internal/protocol/clash.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
func GenerateClash(servers []model.Server, user model.User) (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
builder.WriteString("proxies:\n")
|
||||
var proxyNames []string
|
||||
for _, s := range servers {
|
||||
// Basic VMess conversion for Clash
|
||||
proxy := fmt.Sprintf(" - name: \"%s\"\n type: vmess\n server: %s\n port: %s\n uuid: %s\n alterId: 0\n cipher: auto\n",
|
||||
s.Name, s.Host, s.Port, user.UUID)
|
||||
builder.WriteString(proxy)
|
||||
proxyNames = append(proxyNames, fmt.Sprintf("\"%s\"", s.Name))
|
||||
}
|
||||
|
||||
builder.WriteString("\nproxy-groups:\n")
|
||||
builder.WriteString(" - name: Proxy\n type: select\n proxies:\n - DIRECT\n")
|
||||
for _, name := range proxyNames {
|
||||
builder.WriteString(" - " + name + "\n")
|
||||
}
|
||||
|
||||
builder.WriteString("\nrules:\n")
|
||||
builder.WriteString(" - MATCH,Proxy\n")
|
||||
|
||||
return builder.String(), nil
|
||||
}
|
||||
42
internal/protocol/singbox.go
Normal file
42
internal/protocol/singbox.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package protocol
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
type SingBoxConfig struct {
|
||||
Outbounds []map[string]interface{} `json:"outbounds"`
|
||||
}
|
||||
|
||||
func GenerateSingBox(servers []model.Server, user model.User) (string, error) {
|
||||
config := SingBoxConfig{
|
||||
Outbounds: []map[string]interface{}{},
|
||||
}
|
||||
|
||||
// Add selector outbound
|
||||
selector := map[string]interface{}{
|
||||
"type": "selector",
|
||||
"tag": "Proxy",
|
||||
"outbounds": []string{},
|
||||
}
|
||||
|
||||
for _, s := range servers {
|
||||
outbound := map[string]interface{}{
|
||||
"type": s.Type,
|
||||
"tag": s.Name,
|
||||
"server": s.Host,
|
||||
"server_port": s.Port,
|
||||
}
|
||||
// Add protocol-specific settings
|
||||
// ... logic to handle VMess, Shadowsocks, etc.
|
||||
|
||||
config.Outbounds = append(config.Outbounds, outbound)
|
||||
selector["outbounds"] = append(selector["outbounds"].([]string), s.Name)
|
||||
}
|
||||
|
||||
config.Outbounds = append(config.Outbounds, selector)
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
return string(data), err
|
||||
}
|
||||
97
internal/service/device_state.go
Normal file
97
internal/service/device_state.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
)
|
||||
|
||||
const deviceStateTTL = 10 * time.Minute
|
||||
|
||||
type userDevicesSnapshot map[string][]string
|
||||
|
||||
func SaveUserNodeDevices(userID, nodeID int, ips []string) error {
|
||||
unique := make([]string, 0, len(ips))
|
||||
seen := make(map[string]struct{})
|
||||
for _, ip := range ips {
|
||||
trimmed := strings.TrimSpace(ip)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[trimmed]; ok {
|
||||
continue
|
||||
}
|
||||
seen[trimmed] = struct{}{}
|
||||
unique = append(unique, trimmed)
|
||||
}
|
||||
sort.Strings(unique)
|
||||
|
||||
return database.CacheSet(deviceStateKey(userID, nodeID), unique, deviceStateTTL)
|
||||
}
|
||||
|
||||
func GetUsersDevices(userIDs []int) map[int][]string {
|
||||
result := make(map[int][]string, len(userIDs))
|
||||
|
||||
for _, userID := range userIDs {
|
||||
snapshot, ok := database.CacheGetJSON[userDevicesSnapshot](deviceStateUserIndexKey(userID))
|
||||
if !ok {
|
||||
result[userID] = []string{}
|
||||
continue
|
||||
}
|
||||
|
||||
merged := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, ips := range snapshot {
|
||||
for _, ip := range ips {
|
||||
if _, exists := seen[ip]; exists {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
merged = append(merged, ip)
|
||||
}
|
||||
}
|
||||
sort.Strings(merged)
|
||||
result[userID] = merged
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func SetDevices(userID, nodeID int, ips []string) error {
|
||||
if err := SaveUserNodeDevices(userID, nodeID, ips); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
indexKey := deviceStateUserIndexKey(userID)
|
||||
indexSnapshot, _ := database.CacheGetJSON[userDevicesSnapshot](indexKey)
|
||||
indexSnapshot[fmt.Sprintf("%d", nodeID)] = normalizeIPs(ips)
|
||||
return database.CacheSet(indexKey, indexSnapshot, deviceStateTTL)
|
||||
}
|
||||
|
||||
func normalizeIPs(ips []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]string, 0, len(ips))
|
||||
for _, ip := range ips {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[ip]; ok {
|
||||
continue
|
||||
}
|
||||
seen[ip] = struct{}{}
|
||||
result = append(result, ip)
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func deviceStateKey(userID, nodeID int) string {
|
||||
return fmt.Sprintf("device_state:user:%d:node:%d", userID, nodeID)
|
||||
}
|
||||
|
||||
func deviceStateUserIndexKey(userID int) string {
|
||||
return fmt.Sprintf("device_state:user:%d:index", userID)
|
||||
}
|
||||
437
internal/service/node.go
Normal file
437
internal/service/node.go
Normal file
@@ -0,0 +1,437 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var serverTypeAliases = map[string]string{
|
||||
"v2ray": "vmess",
|
||||
"hysteria2": "hysteria",
|
||||
}
|
||||
|
||||
var validServerTypes = map[string]struct{}{
|
||||
"anytls": {},
|
||||
"http": {},
|
||||
"hysteria": {},
|
||||
"mieru": {},
|
||||
"naive": {},
|
||||
"shadowsocks": {},
|
||||
"socks": {},
|
||||
"trojan": {},
|
||||
"tuic": {},
|
||||
"vless": {},
|
||||
"vmess": {},
|
||||
}
|
||||
|
||||
type NodeUser struct {
|
||||
ID int `json:"id"`
|
||||
UUID string `json:"uuid"`
|
||||
SpeedLimit *int `json:"speed_limit,omitempty"`
|
||||
DeviceLimit *int `json:"device_limit,omitempty"`
|
||||
}
|
||||
|
||||
func NormalizeServerType(serverType string) string {
|
||||
serverType = strings.ToLower(strings.TrimSpace(serverType))
|
||||
if serverType == "" {
|
||||
return ""
|
||||
}
|
||||
if alias, ok := serverTypeAliases[serverType]; ok {
|
||||
return alias
|
||||
}
|
||||
return serverType
|
||||
}
|
||||
|
||||
func IsValidServerType(serverType string) bool {
|
||||
if serverType == "" {
|
||||
return true
|
||||
}
|
||||
_, ok := validServerTypes[NormalizeServerType(serverType)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FindServer(nodeID, nodeType string) (*model.Server, error) {
|
||||
query := database.DB.Model(&model.Server{})
|
||||
if normalized := NormalizeServerType(nodeType); normalized != "" {
|
||||
query = query.Where("type = ?", normalized)
|
||||
}
|
||||
|
||||
var server model.Server
|
||||
if err := query.
|
||||
Where("code = ? OR id = ?", nodeID, nodeID).
|
||||
Order(gorm.Expr("CASE WHEN code = ? THEN 0 ELSE 1 END", nodeID)).
|
||||
First(&server).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server.Type = NormalizeServerType(server.Type)
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
func AvailableUsersForNode(node *model.Server) ([]NodeUser, error) {
|
||||
groupIDs := parseIntSlice(node.GroupIDs)
|
||||
if len(groupIDs) == 0 {
|
||||
return []NodeUser{}, nil
|
||||
}
|
||||
|
||||
var users []NodeUser
|
||||
err := database.DB.Model(&model.User{}).
|
||||
Select("id", "uuid", "speed_limit", "device_limit").
|
||||
Where("group_id IN ?", groupIDs).
|
||||
Where("u + d < transfer_enable").
|
||||
Where("(expired_at >= ? OR expired_at IS NULL)", time.Now().Unix()).
|
||||
Where("banned = ?", 0).
|
||||
Scan(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func AvailableServersForUser(user *model.User) ([]model.Server, error) {
|
||||
var servers []model.Server
|
||||
if err := database.DB.Where("`show` = ?", 1).Order("sort ASC").Find(&servers).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]model.Server, 0, len(servers))
|
||||
for _, server := range servers {
|
||||
groupIDs := parseIntSlice(server.GroupIDs)
|
||||
if user.GroupID != nil && len(groupIDs) > 0 && !containsInt(groupIDs, *user.GroupID) {
|
||||
continue
|
||||
}
|
||||
|
||||
if server.TransferEnable != nil && *server.TransferEnable > 0 && server.U+server.D >= *server.TransferEnable {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, server)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func CurrentRate(server *model.Server) float64 {
|
||||
if !server.RateTimeEnable {
|
||||
return float64(server.Rate)
|
||||
}
|
||||
|
||||
ranges := parseObjectSlice(server.RateTimeRanges)
|
||||
now := time.Now().Format("15:04")
|
||||
for _, item := range ranges {
|
||||
start, _ := item["start"].(string)
|
||||
end, _ := item["end"].(string)
|
||||
if start != "" && end != "" && now >= start && now <= end {
|
||||
if rate, ok := toFloat64(item["rate"]); ok {
|
||||
return rate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return float64(server.Rate)
|
||||
}
|
||||
|
||||
func BuildNodeConfig(node *model.Server) map[string]any {
|
||||
settings := parseObject(node.ProtocolSettings)
|
||||
response := map[string]any{
|
||||
"protocol": node.Type,
|
||||
"listen_ip": "0.0.0.0",
|
||||
"server_port": node.ServerPort,
|
||||
"network": getMapString(settings, "network"),
|
||||
"networkSettings": getMapAny(settings, "network_settings"),
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case "shadowsocks":
|
||||
response["cipher"] = getMapString(settings, "cipher")
|
||||
response["plugin"] = getMapString(settings, "plugin")
|
||||
response["plugin_opts"] = getMapString(settings, "plugin_opts")
|
||||
cipher := getMapString(settings, "cipher")
|
||||
switch cipher {
|
||||
case "2022-blake3-aes-128-gcm":
|
||||
response["server_key"] = serverKey(node.CreatedAt, 16)
|
||||
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
|
||||
response["server_key"] = serverKey(node.CreatedAt, 32)
|
||||
}
|
||||
case "vmess":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
case "trojan":
|
||||
response["host"] = node.Host
|
||||
response["server_name"] = getMapString(settings, "server_name")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
if getMapInt(settings, "tls") == 2 {
|
||||
response["tls_settings"] = getMapAny(settings, "reality_settings")
|
||||
}
|
||||
case "vless":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["flow"] = getMapString(settings, "flow")
|
||||
response["multiplex"] = getMapAny(settings, "multiplex")
|
||||
if encryption, ok := settings["encryption"].(map[string]any); ok {
|
||||
if enabled, ok := encryption["enabled"].(bool); ok && enabled {
|
||||
response["decryption"] = stringify(encryption["decryption"])
|
||||
}
|
||||
}
|
||||
if getMapInt(settings, "tls") == 2 {
|
||||
response["tls_settings"] = getMapAny(settings, "reality_settings")
|
||||
} else {
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
}
|
||||
case "hysteria":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
obfs, _ := settings["obfs"].(map[string]any)
|
||||
bandwidth, _ := settings["bandwidth"].(map[string]any)
|
||||
version := getMapInt(settings, "version")
|
||||
response["version"] = version
|
||||
response["host"] = node.Host
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["up_mbps"] = mapAnyInt(bandwidth, "up")
|
||||
response["down_mbps"] = mapAnyInt(bandwidth, "down")
|
||||
if version == 1 {
|
||||
response["obfs"] = stringify(obfs["password"])
|
||||
} else if version == 2 {
|
||||
if open, ok := obfs["open"].(bool); ok && open {
|
||||
response["obfs"] = stringify(obfs["type"])
|
||||
response["obfs-password"] = stringify(obfs["password"])
|
||||
}
|
||||
}
|
||||
case "tuic":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
response["version"] = getMapInt(settings, "version")
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["congestion_control"] = getMapString(settings, "congestion_control")
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
response["auth_timeout"] = "3s"
|
||||
response["zero_rtt_handshake"] = false
|
||||
response["heartbeat"] = "3s"
|
||||
case "anytls":
|
||||
tls, _ := settings["tls"].(map[string]any)
|
||||
response["server_name"] = stringify(tls["server_name"])
|
||||
response["padding_scheme"] = getMapAny(settings, "padding_scheme")
|
||||
case "naive", "http":
|
||||
response["tls"] = getMapInt(settings, "tls")
|
||||
response["tls_settings"] = getMapAny(settings, "tls_settings")
|
||||
case "mieru":
|
||||
response["transport"] = getMapString(settings, "transport")
|
||||
response["traffic_pattern"] = getMapString(settings, "traffic_pattern")
|
||||
}
|
||||
|
||||
if routeIDs := parseIntSlice(node.RouteIDs); len(routeIDs) > 0 {
|
||||
var routes []model.ServerRoute
|
||||
if err := database.DB.Select("id", "`match`", "action", "action_value").Where("id IN ?", routeIDs).Find(&routes).Error; err == nil {
|
||||
response["routes"] = routes
|
||||
}
|
||||
}
|
||||
if value := parseGenericJSON(node.CustomOutbounds); value != nil {
|
||||
response["custom_outbounds"] = value
|
||||
}
|
||||
if value := parseGenericJSON(node.CustomRoutes); value != nil {
|
||||
response["custom_routes"] = value
|
||||
}
|
||||
if value := parseGenericJSON(node.CertConfig); value != nil {
|
||||
response["cert_config"] = value
|
||||
}
|
||||
|
||||
return pruneNilMap(response)
|
||||
}
|
||||
|
||||
func ApplyTrafficDelta(userID int, node *model.Server, upload, download int64) {
|
||||
rate := CurrentRate(node)
|
||||
scaledUpload := int64(math.Round(float64(upload) * rate))
|
||||
scaledDownload := int64(math.Round(float64(download) * rate))
|
||||
|
||||
database.DB.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Updates(map[string]any{
|
||||
"u": gorm.Expr("u + ?", scaledUpload),
|
||||
"d": gorm.Expr("d + ?", scaledDownload),
|
||||
"t": time.Now().Unix(),
|
||||
})
|
||||
|
||||
database.DB.Model(&model.Server{}).
|
||||
Where("id = ?", node.ID).
|
||||
Updates(map[string]any{
|
||||
"u": gorm.Expr("u + ?", scaledUpload),
|
||||
"d": gorm.Expr("d + ?", scaledDownload),
|
||||
})
|
||||
}
|
||||
|
||||
func serverKey(createdAt *time.Time, size int) string {
|
||||
if createdAt == nil {
|
||||
return ""
|
||||
}
|
||||
sum := md5.Sum([]byte(strconv.FormatInt(createdAt.Unix(), 10)))
|
||||
hex := fmt.Sprintf("%x", sum)
|
||||
if size > len(hex) {
|
||||
size = len(hex)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString([]byte(hex[:size]))
|
||||
}
|
||||
|
||||
func parseIntSlice(raw *string) []int {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var decoded []any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err == nil {
|
||||
result := make([]int, 0, len(decoded))
|
||||
for _, item := range decoded {
|
||||
if value, ok := toInt(item); ok {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
parts := strings.Split(*raw, ",")
|
||||
result := make([]int, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if value, err := strconv.Atoi(strings.TrimSpace(part)); err == nil {
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseObject(raw *string) map[string]any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func parseObjectSlice(raw *string) []map[string]any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var decoded []map[string]any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func parseGenericJSON(raw *string) any {
|
||||
if raw == nil || strings.TrimSpace(*raw) == "" {
|
||||
return nil
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*raw), &decoded); err != nil {
|
||||
return nil
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
func getMapString(values map[string]any, key string) string {
|
||||
return stringify(values[key])
|
||||
}
|
||||
|
||||
func getMapInt(values map[string]any, key string) int {
|
||||
if value, ok := toInt(values[key]); ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getMapAny(values map[string]any, key string) any {
|
||||
if value, ok := values[key]; ok {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mapAnyInt(values map[string]any, key string) int {
|
||||
if value, ok := toInt(values[key]); ok {
|
||||
return value
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func stringify(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
case int:
|
||||
return strconv.Itoa(typed)
|
||||
case int64:
|
||||
return strconv.FormatInt(typed, 10)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func pruneNilMap(values map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if text, ok := value.(string); ok && text == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case int64:
|
||||
return int(typed), true
|
||||
case float64:
|
||||
return int(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat64(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case int:
|
||||
return float64(typed), true
|
||||
case int64:
|
||||
return float64(typed), true
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func containsInt(values []int, target int) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
158
internal/service/plugin.go
Normal file
158
internal/service/plugin.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
PluginRealNameVerification = "real_name_verification"
|
||||
PluginUserOnlineDevices = "user_online_devices"
|
||||
PluginUserAddIPv6 = "user_add_ipv6_subscription"
|
||||
)
|
||||
|
||||
func GetPlugin(code string) (*model.Plugin, bool) {
|
||||
var plugin model.Plugin
|
||||
if err := database.DB.Where("code = ?", code).First(&plugin).Error; err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &plugin, true
|
||||
}
|
||||
|
||||
func IsPluginEnabled(code string) bool {
|
||||
plugin, ok := GetPlugin(code)
|
||||
return ok && plugin.IsEnabled
|
||||
}
|
||||
|
||||
func GetPluginConfig(code string) map[string]any {
|
||||
plugin, ok := GetPlugin(code)
|
||||
if !ok || plugin.Config == nil || *plugin.Config == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(*plugin.Config), &cfg); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func GetPluginConfigString(code, key, defaultValue string) string {
|
||||
cfg := GetPluginConfig(code)
|
||||
value, ok := cfg[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
if raw, ok := value.(string); ok && raw != "" {
|
||||
return raw
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func GetPluginConfigBool(code, key string, defaultValue bool) bool {
|
||||
cfg := GetPluginConfig(code)
|
||||
value, ok := cfg[key]
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
return typed
|
||||
case float64:
|
||||
return typed != 0
|
||||
case string:
|
||||
return typed == "1" || typed == "true"
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func SyncIPv6ShadowAccount(user *model.User) bool {
|
||||
if user == nil || user.PlanID == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var plan model.Plan
|
||||
if err := database.DB.First(&plan, *user.PlanID).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
if !PluginPlanAllowed(&plan) {
|
||||
return false
|
||||
}
|
||||
|
||||
ipv6Email := IPv6ShadowEmail(user.Email)
|
||||
var ipv6User model.User
|
||||
now := time.Now().Unix()
|
||||
if err := database.DB.Where("email = ?", ipv6Email).First(&ipv6User).Error; err != nil {
|
||||
ipv6User = *user
|
||||
ipv6User.ID = 0
|
||||
ipv6User.Email = ipv6Email
|
||||
ipv6User.UUID = uuid.New().String()
|
||||
ipv6User.Token = fmt.Sprintf("%x", md5.Sum([]byte(time.Now().String()+ipv6Email)))[:16]
|
||||
ipv6User.U = 0
|
||||
ipv6User.D = 0
|
||||
ipv6User.T = 0
|
||||
ipv6User.ParentID = &user.ID
|
||||
ipv6User.CreatedAt = now
|
||||
}
|
||||
|
||||
ipv6User.Email = ipv6Email
|
||||
ipv6User.Password = user.Password
|
||||
ipv6User.U = 0
|
||||
ipv6User.D = 0
|
||||
ipv6User.T = 0
|
||||
ipv6User.UpdatedAt = now
|
||||
|
||||
if planID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_plan_id", "0"), 0); planID > 0 {
|
||||
ipv6User.PlanID = &planID
|
||||
}
|
||||
if groupID := parsePluginPositiveInt(GetPluginConfigString(PluginUserAddIPv6, "ipv6_group_id", "0"), 0); groupID > 0 {
|
||||
ipv6User.GroupID = &groupID
|
||||
}
|
||||
|
||||
return database.DB.Save(&ipv6User).Error == nil
|
||||
}
|
||||
|
||||
func PluginPlanAllowed(plan *model.Plan) bool {
|
||||
if plan == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, raw := range strings.Split(GetPluginConfigString(PluginUserAddIPv6, "allowed_plans", ""), ",") {
|
||||
if parsePluginPositiveInt(strings.TrimSpace(raw), 0) == plan.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
referenceFlag := strings.ToLower(GetPluginConfigString(PluginUserAddIPv6, "reference_flag", "ipv6"))
|
||||
reference := ""
|
||||
if plan.Reference != nil {
|
||||
reference = strings.ToLower(*plan.Reference)
|
||||
}
|
||||
return referenceFlag != "" && strings.Contains(reference, referenceFlag)
|
||||
}
|
||||
|
||||
func IPv6ShadowEmail(email string) string {
|
||||
suffix := GetPluginConfigString(PluginUserAddIPv6, "email_suffix", "-ipv6")
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return email
|
||||
}
|
||||
return parts[0] + suffix + "@" + parts[1]
|
||||
}
|
||||
|
||||
func parsePluginPositiveInt(raw string, defaultValue int) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || value <= 0 {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
208
internal/service/session.go
Normal file
208
internal/service/session.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
"xboard-go/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const sessionTTL = 7 * 24 * time.Hour
|
||||
|
||||
type SessionRecord struct {
|
||||
ID string `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
TokenHash string `json:"token_hash"`
|
||||
Name string `json:"name"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
IP string `json:"ip"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastUsedAt int64 `json:"last_used_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func TrackSession(userID int, token, ip, userAgent string) SessionRecord {
|
||||
now := time.Now().Unix()
|
||||
tokenHash := hashToken(token)
|
||||
list := activeSessions(userID, now)
|
||||
|
||||
for i := range list {
|
||||
if list[i].TokenHash == tokenHash {
|
||||
list[i].IP = firstNonEmpty(ip, list[i].IP)
|
||||
list[i].UserAgent = firstNonEmpty(userAgent, list[i].UserAgent)
|
||||
list[i].LastUsedAt = now
|
||||
saveSessions(userID, list)
|
||||
return list[i]
|
||||
}
|
||||
}
|
||||
|
||||
record := SessionRecord{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: tokenHash,
|
||||
Name: "Web Session",
|
||||
UserAgent: userAgent,
|
||||
IP: ip,
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now + int64(sessionTTL.Seconds()),
|
||||
}
|
||||
list = append(list, record)
|
||||
saveSessions(userID, list)
|
||||
return record
|
||||
}
|
||||
|
||||
func GetUserSessions(userID int, currentToken string) []SessionRecord {
|
||||
now := time.Now().Unix()
|
||||
list := activeSessions(userID, now)
|
||||
if currentToken != "" {
|
||||
currentHash := hashToken(currentToken)
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].TokenHash == currentHash {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
list = append(list, SessionRecord{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
TokenHash: currentHash,
|
||||
Name: "Web Session",
|
||||
CreatedAt: now,
|
||||
LastUsedAt: now,
|
||||
ExpiresAt: now + int64(sessionTTL.Seconds()),
|
||||
})
|
||||
saveSessions(userID, list)
|
||||
}
|
||||
}
|
||||
return activeSessions(userID, now)
|
||||
}
|
||||
|
||||
func RemoveUserSession(userID int, sessionID string) bool {
|
||||
now := time.Now().Unix()
|
||||
list := activeSessions(userID, now)
|
||||
next := make([]SessionRecord, 0, len(list))
|
||||
removed := false
|
||||
|
||||
for _, session := range list {
|
||||
if session.ID != sessionID {
|
||||
next = append(next, session)
|
||||
continue
|
||||
}
|
||||
|
||||
removed = true
|
||||
ttl := time.Until(time.Unix(session.ExpiresAt, 0))
|
||||
if ttl < time.Hour {
|
||||
ttl = time.Hour
|
||||
}
|
||||
_ = database.CacheSet(revokedTokenKey(session.TokenHash), true, ttl)
|
||||
}
|
||||
|
||||
if removed {
|
||||
saveSessions(userID, next)
|
||||
}
|
||||
|
||||
return removed
|
||||
}
|
||||
|
||||
func IsSessionTokenRevoked(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := database.CacheGetJSON[bool](revokedTokenKey(hashToken(token)))
|
||||
return ok
|
||||
}
|
||||
|
||||
func StoreQuickLoginToken(userID int, ttl time.Duration) string {
|
||||
verify := uuid.NewString()
|
||||
_ = database.CacheSet(quickLoginKey(verify), userID, ttl)
|
||||
return verify
|
||||
}
|
||||
|
||||
func ResolveQuickLoginToken(verify string) (int, bool) {
|
||||
userID, ok := database.CacheGetJSON[int](quickLoginKey(verify))
|
||||
if ok {
|
||||
_ = database.CacheDelete(quickLoginKey(verify))
|
||||
}
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
func StoreEmailVerifyCode(email, code string, ttl time.Duration) {
|
||||
_ = database.CacheSet(emailVerifyKey(email), code, ttl)
|
||||
}
|
||||
|
||||
func CheckEmailVerifyCode(email, code string) bool {
|
||||
savedCode, ok := database.CacheGetJSON[string](emailVerifyKey(email))
|
||||
if !ok || savedCode == "" || savedCode != code {
|
||||
return false
|
||||
}
|
||||
_ = database.CacheDelete(emailVerifyKey(email))
|
||||
return true
|
||||
}
|
||||
|
||||
func activeSessions(userID int, now int64) []SessionRecord {
|
||||
sessions, ok := database.CacheGetJSON[[]SessionRecord](userSessionsKey(userID))
|
||||
if !ok || len(sessions) == 0 {
|
||||
return []SessionRecord{}
|
||||
}
|
||||
|
||||
filtered := make([]SessionRecord, 0, len(sessions))
|
||||
for _, session := range sessions {
|
||||
if session.ExpiresAt > 0 && session.ExpiresAt <= now {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, session)
|
||||
}
|
||||
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
return filtered[i].LastUsedAt > filtered[j].LastUsedAt
|
||||
})
|
||||
|
||||
if len(filtered) != len(sessions) {
|
||||
saveSessions(userID, filtered)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func saveSessions(userID int, sessions []SessionRecord) {
|
||||
sort.SliceStable(sessions, func(i, j int) bool {
|
||||
return sessions[i].LastUsedAt > sessions[j].LastUsedAt
|
||||
})
|
||||
_ = database.CacheSet(userSessionsKey(userID), sessions, sessionTTL)
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func userSessionsKey(userID int) string {
|
||||
return "session:user:" + strconv.Itoa(userID)
|
||||
}
|
||||
|
||||
func revokedTokenKey(tokenHash string) string {
|
||||
return "session:revoked:" + tokenHash
|
||||
}
|
||||
|
||||
func quickLoginKey(verify string) string {
|
||||
return "session:quick-login:" + verify
|
||||
}
|
||||
|
||||
func emailVerifyKey(email string) string {
|
||||
return "session:email-code:" + email
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
72
internal/service/settings.go
Normal file
72
internal/service/settings.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"xboard-go/internal/config"
|
||||
"xboard-go/internal/database"
|
||||
"xboard-go/internal/model"
|
||||
)
|
||||
|
||||
func GetSetting(name string) (string, bool) {
|
||||
var setting model.Setting
|
||||
if err := database.DB.Select("value").Where("name = ?", name).First(&setting).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return setting.Value, true
|
||||
}
|
||||
|
||||
func MustGetString(name, defaultValue string) string {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func MustGetInt(name string, defaultValue int) int {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func MustGetBool(name string, defaultValue bool) bool {
|
||||
value, ok := GetSetting(name)
|
||||
if !ok {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
func GetAdminSecurePath() string {
|
||||
if securePath := strings.Trim(MustGetString("secure_path", ""), "/"); securePath != "" {
|
||||
return securePath
|
||||
}
|
||||
if frontendPath := strings.Trim(MustGetString("frontend_admin_path", ""), "/"); frontendPath != "" {
|
||||
return frontendPath
|
||||
}
|
||||
return "admin"
|
||||
}
|
||||
|
||||
func GetAppURL() string {
|
||||
if appURL := strings.TrimSpace(MustGetString("app_url", "")); appURL != "" {
|
||||
return strings.TrimRight(appURL, "/")
|
||||
}
|
||||
return strings.TrimRight(config.AppConfig.AppURL, "/")
|
||||
}
|
||||
Reference in New Issue
Block a user