diff --git a/.gitignore b/.gitignore index fbc8c5c..414e717 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ development/ dist/ frontend/admin/reverse/node_modules/ log -sqldes api.exe \ No newline at end of file diff --git a/internal/handler/client_api.go b/internal/handler/client_api.go index bf6d50c..496516f 100644 --- a/internal/handler/client_api.go +++ b/internal/handler/client_api.go @@ -85,6 +85,15 @@ func FulfillSubscription(c *gin.Context, user *model.User) { } // 3b. Add Actual Node Links + if service.MustGetBool("subscribe_protocol_prefix_enable", true) { + for i := range servers { + prefix := strings.ToUpper(servers[i].Type) + if !strings.HasPrefix(servers[i].Name, "["+prefix+"]") { + servers[i].Name = "[" + prefix + "] " + servers[i].Name + } + } + } + nodeLinks := protocol.GenerateGeneralLinks(servers, *user) if nodeLinks != "" { finalLinks = append(finalLinks, strings.Split(nodeLinks, "\n")...) @@ -128,6 +137,11 @@ func generateInfoNodes(user *model.User) []string { } nodes = append(nodes, buildInfoLink("套餐到期:"+expireDate)) + // Reset Day Info + if days := service.GetResetDay(user); days != nil { + nodes = append(nodes, buildInfoLink(fmt.Sprintf("距离重置: %d 天", *days))) + } + // Traffic Info remaining := int64(user.TransferEnable) - (int64(user.U) + int64(user.D)) if remaining < 0 { @@ -197,15 +211,25 @@ func ClientAppConfigV2(c *gin.Context) { "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), + "ui_config": gin.H{ + "app_primary_color": service.MustGetString("app_primary_color", "#000000"), + "app_home_screen_style": service.MustGetString("app_home_screen_style", "default"), + "app_server_list_style": service.MustGetString("app_server_list_style", "default"), + "app_theme_mode": service.MustGetString("app_theme_mode", "light"), + "app_show_traffic_chart": service.MustGetBool("app_show_traffic_chart", true), + }, + "business_rules": 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), + "enable_speed_test": service.MustGetBool("app_enable_speed_test", true), + "enable_global_search": service.MustGetBool("app_enable_global_search", true), + "enable_real_name_verification": service.MustGetBool("app_enable_real_name_verification", false), }, "security_config": gin.H{ "tos_url": service.MustGetString("tos_url", ""), @@ -218,6 +242,10 @@ func ClientAppConfigV2(c *gin.Context) { "recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""), "turnstile_site_key": service.MustGetString("turnstile_site_key", ""), }, + "server_config": gin.H{ + "default_kernel": service.MustGetString("default_kernel", "sing-box"), + "default_protocol": service.MustGetString("default_protocol", "vless"), + }, } Success(c, config) diff --git a/internal/handler/guest_api.go b/internal/handler/guest_api.go index a5dae02..1cff87f 100644 --- a/internal/handler/guest_api.go +++ b/internal/handler/guest_api.go @@ -17,7 +17,7 @@ func buildGuestConfig() 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", ""), + "email_whitelist_suffix": getEmailWhitelistSuffix(), "is_captcha": boolToInt(service.MustGetBool("captcha_enable", false)), "captcha_type": service.MustGetString("captcha_type", "recaptcha"), "recaptcha_site_key": service.MustGetString("recaptcha_site_key", ""), @@ -44,7 +44,20 @@ func GuestPlanFetch(c *gin.Context) { Fail(c, 500, "failed to fetch plans") return } - Success(c, plans) + + formatted := make([]gin.H, 0, len(plans)) + for i := range plans { + formatted = append(formatted, service.FormatPlan(&plans[i])) + } + + Success(c, formatted) +} + +func getEmailWhitelistSuffix() any { + if !service.MustGetBool("email_whitelist_enable", false) { + return 0 + } + return service.MustGetString("email_whitelist_suffix", "") } func boolToInt(value bool) int { diff --git a/internal/handler/node_api.go b/internal/handler/node_api.go index 020613f..d69e21f 100644 --- a/internal/handler/node_api.go +++ b/internal/handler/node_api.go @@ -26,7 +26,7 @@ func NodeUser(c *gin.Context) { return } - Success(c, gin.H{"users": users}) + c.JSON(http.StatusOK, gin.H{"users": users}) } func NodeShadowsocksTidalabUser(c *gin.Context) { diff --git a/internal/handler/user_api.go b/internal/handler/user_api.go index aeac7e3..de88608 100644 --- a/internal/handler/user_api.go +++ b/internal/handler/user_api.go @@ -89,9 +89,9 @@ func UserGetSubscribe(c *gin.Context) { "device_limit": user.DeviceLimit, "speed_limit": user.SpeedLimit, "next_reset_at": user.NextResetAt, - "plan": user.Plan, + "plan": service.FormatPlan(user.Plan), "subscribe_url": strings.TrimRight(baseURL, "/") + "/" + subscribePath + "/" + user.Token, - "reset_day": nil, + "reset_day": service.GetResetDay(user), }) } diff --git a/internal/service/node.go b/internal/service/node.go index 430548e..72fbb9d 100644 --- a/internal/service/node.go +++ b/internal/service/node.go @@ -56,8 +56,8 @@ var shadowsocks2022CipherConfigs = map[string]struct { type NodeUser struct { ID int `json:"id"` UUID string `json:"uuid"` - SpeedLimit *int `json:"speed_limit,omitempty"` - DeviceLimit *int `json:"device_limit,omitempty"` + SpeedLimit *int `json:"speed_limit"` + DeviceLimit *int `json:"device_limit"` } type NodeBaseConfig struct { @@ -158,8 +158,9 @@ func AvailableUsersForNode(node *model.Server) ([]NodeUser, error) { Where("(expired_at >= ? OR expired_at IS NULL)", time.Now().Unix()). Where("banned = ?", 0). Scan(&users).Error + if err != nil { - return nil, err + return []NodeUser{}, err } return users, nil @@ -373,7 +374,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any { switch getMapString(settings, "cipher") { case "2022-blake3-aes-128-gcm": response["server_key"] = serverKey(node.CreatedAt, 16) - case "2022-blake3-aes-256-gcm": + case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305": response["server_key"] = serverKey(node.CreatedAt, 32) default: response["server_key"] = nil @@ -395,7 +396,7 @@ func BuildNodeConfigPayload(node *model.Server) map[string]any { response["tls"] = getMapInt(settings, "tls") response["flow"] = getMapAny(settings, "flow") response["multiplex"] = getMapAny(settings, "multiplex") - response["decryption"] = "none" + response["decryption"] = nil if encryption, ok := settings["encryption"].(map[string]any); ok { if enabled, ok := encryption["enabled"].(bool); ok && enabled { if dec := stringify(encryption["decryption"]); dec != "" { diff --git a/internal/service/plan.go b/internal/service/plan.go new file mode 100644 index 0000000..8171ede --- /dev/null +++ b/internal/service/plan.go @@ -0,0 +1,125 @@ +package service + +import ( + "encoding/json" + "strconv" + "strings" + "xboard-go/internal/model" + + "github.com/gin-gonic/gin" +) + +var legacyPeriodMapping = map[string]string{ + "month_price": "monthly", + "quarter_price": "quarterly", + "half_year_price": "half_yearly", + "year_price": "yearly", + "two_year_price": "two_yearly", + "three_year_price": "three_yearly", + "onetime_price": "onetime", + "reset_price": "reset_traffic", +} + +func FormatPlan(plan *model.Plan) gin.H { + if plan == nil { + return nil + } + + res := gin.H{ + "id": plan.ID, + "group_id": plan.GroupID, + "name": plan.Name, + "tags": parseTags(plan.Tags), + "transfer_enable": plan.TransferEnable, + "speed_limit": plan.SpeedLimit, + "device_limit": plan.DeviceLimit, + "show": plan.Show, + "sell": plan.Sell, + "renew": plan.Renew, + "reset_traffic_method": plan.ResetTrafficMethod, + "sort": plan.Sort, + "created_at": plan.CreatedAt, + "updated_at": plan.UpdatedAt, + "capacity_limit": plan.CapacityLimit, + } + + // 1. Format Pricing (Legacy Keys + 100x Multiplier) + var prices map[string]float64 + if plan.Prices != nil && *plan.Prices != "" { + _ = json.Unmarshal([]byte(*plan.Prices), &prices) + } + + for legacyKey, newPeriod := range legacyPeriodMapping { + if price, ok := prices[newPeriod]; ok { + res[legacyKey] = int64(price * 100) + } else { + res[legacyKey] = nil + } + } + + // 2. Format Content (Placeholders) + content := "" + if plan.Content != nil { + content = *plan.Content + } + res["content"] = formatPlanContent(content, plan) + + return res +} + +func formatPlanContent(content string, plan *model.Plan) string { + if content == "" { + return "" + } + + replacements := map[string]string{ + "{{transfer}}": formatIntPtr(plan.TransferEnable), + "{{speed}}": formatIntPtr(plan.SpeedLimit), + "{{devices}}": formatIntPtr(plan.DeviceLimit), + "{{reset_method}}": getResetMethodText(plan.ResetTrafficMethod), + } + + for k, v := range replacements { + content = strings.ReplaceAll(content, k, v) + } + + return content +} + +func formatIntPtr(val *int) string { + if val == nil { + return "无限制" // Localized or default + } + return strings.TrimSpace(strconv.Itoa(*val)) +} + +func getResetMethodText(method *int) string { + if method == nil { + return "每月重置" + } + switch *method { + case 0: + return "每月1号" + case 1: + return "按月重置" + case 2: + return "不重置" + case 3: + return "每年1月1日" + case 4: + return "按年重置" + default: + return "按月重置" + } +} + +func parseTags(tags *string) []string { + if tags == nil || *tags == "" { + return []string{} + } + var res []string + if err := json.Unmarshal([]byte(*tags), &res); err == nil { + return res + } + return strings.Split(*tags, ",") +} diff --git a/internal/service/traffic_reset.go b/internal/service/traffic_reset.go new file mode 100644 index 0000000..73ce498 --- /dev/null +++ b/internal/service/traffic_reset.go @@ -0,0 +1,119 @@ +package service + +import ( + "strings" + "time" + "xboard-go/internal/database" + "xboard-go/internal/model" +) + +const ( + ResetTrafficFirstDayMonth = 0 + ResetTrafficMonthly = 1 + ResetTrafficNever = 2 + ResetTrafficFirstDayYear = 3 + ResetTrafficYearly = 4 +) + +func GetResetDay(user *model.User) *int { + nextResetTime := CalculateNextResetTime(user) + if nextResetTime == nil { + return nil + } + + now := time.Now() + if nextResetTime.Before(now) { + zero := 0 + return &zero + } + + days := int(nextResetTime.Sub(now).Hours()/24) + 1 + return &days +} + +func CalculateNextResetTime(user *model.User) *time.Time { + var plan model.Plan + if user.PlanID == nil { + return nil + } + + // We need to fetch the plan's reset method. + // For simplicity in this service, we assume the caller has access or we fetch it here. + // However, usually it's better to pass the plan or the reset method. + // Let's implement it carefully. + + // If the user has no plan or reset method is never, return nil. + // Since we don't have the plan object here, we'll need to fetch it if not provided. + // For now, let's look at how Xboard does it (it uses $user->plan). + + // Wait, I should check if I can get the plan easily. + var resetMethod int + if err := database.DB.Model(&model.Plan{}).Select("reset_traffic_method").Where("id = ?", *user.PlanID).Scan(&resetMethod).Error; err != nil { + return nil + } + + if resetMethod == ResetTrafficNever || user.ExpiredAt == nil { + return nil + } + + now := time.Now() + expiredAt := time.Unix(*user.ExpiredAt, 0) + + switch resetMethod { + case ResetTrafficFirstDayMonth: + return getNextMonthFirstDay(now) + case ResetTrafficMonthly: + return getNextMonthlyReset(user, now, expiredAt) + case ResetTrafficFirstDayYear: + return getNextYearFirstDay(now) + case ResetTrafficYearly: + return getNextYearlyReset(user, now, expiredAt) + default: + return nil + } +} + +func getNextMonthFirstDay(from time.Time) *time.Time { + t := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()).AddDate(0, 1, 0) + return &t +} + +func getNextMonthlyReset(user *model.User, from time.Time, expiredAt time.Time) *time.Time { + resetDay := expiredAt.Day() + + // Try current month + target := time.Date(from.Year(), from.Month(), resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location()) + if target.After(from) { + return &target + } + + // Try next month + target = target.AddDate(0, 1, 0) + // Handle cases where next month doesn't have that day (e.g. Feb 30) + if target.Day() != resetDay { + // Go back to last day of intended month + target = time.Date(target.Year(), target.Month(), 0, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location()) + } + + return &target +} + +func getNextYearFirstDay(from time.Time) *time.Time { + t := time.Date(from.Year()+1, 1, 1, 0, 0, 0, 0, from.Location()) + return &t +} + +func getNextYearlyReset(user *model.User, from time.Time, expiredAt time.Time) *time.Time { + resetMonth := expiredAt.Month() + resetDay := expiredAt.Day() + + // Try current year + target := time.Date(from.Year(), resetMonth, resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location()) + if target.After(from) { + return &target + } + + // Try next year + target = time.Date(from.Year()+1, resetMonth, resetDay, expiredAt.Hour(), expiredAt.Minute(), expiredAt.Second(), 0, from.Location()) + return &target +}