diff --git a/frontend/admin/reverse/output/index-CO3BwsT2.pretty.js b/frontend/admin/reverse/output/index-CO3BwsT2.pretty.js index 87d7adf..b1b03d2 100644 --- a/frontend/admin/reverse/output/index-CO3BwsT2.pretty.js +++ b/frontend/admin/reverse/output/index-CO3BwsT2.pretty.js @@ -264910,7 +264910,7 @@ function dqt({ className: e }) { e.change >= 0 ? Q.jsx(iat, { className: "mr-1 h-3 w-3" }) : Q.jsx(eat, { className: "mr-1 h-3 w-3" }), - Math.abs(e.change), + Math.abs(Number(e.change || 0)).toFixed(2), "%", ], }), @@ -264971,7 +264971,11 @@ function dqt({ className: e }) { "font-medium", e.change >= 0 ? "text-green-600" : "text-red-600", ), - children: [e.change >= 0 ? "+" : "", e.change, "%"], + children: [ + e.change >= 0 ? "+" : "", + Number(e.change || 0).toFixed(2), + "%", + ], }), ], }), @@ -265063,7 +265067,7 @@ function dqt({ className: e }) { e.change >= 0 ? Q.jsx(iat, { className: "mr-1 h-3 w-3" }) : Q.jsx(eat, { className: "mr-1 h-3 w-3" }), - Math.abs(e.change), + Math.abs(Number(e.change || 0)).toFixed(2), "%", ], }), @@ -265124,7 +265128,11 @@ function dqt({ className: e }) { "font-medium", e.change >= 0 ? "text-green-600" : "text-red-600", ), - children: [e.change >= 0 ? "+" : "", e.change, "%"], + children: [ + e.change >= 0 ? "+" : "", + Number(e.change || 0).toFixed(2), + "%", + ], }), ], }), @@ -272190,8 +272198,8 @@ function HGt({ open: e, onOpenChange: t, result: n }) { Q.jsx("div", { children: n.config.encryption || "无" }), Q.jsx("div", { className: "text-muted-foreground", children: "发件人" }), Q.jsx("div", { - children: n.config.from.address - ? `${n.config.from.address}${n.config.from.name ? ` (${n.config.from.name})` : ""}` + children: (n.config.from?.address || n.config.from_address) + ? `${n.config.from?.address || n.config.from_address}${n.config.from?.name || n.config.from_name ? ` (${n.config.from?.name || n.config.from_name})` : ""}` : "未设置", }), Q.jsx("div", { className: "text-muted-foreground", children: "用户名" }), diff --git a/internal/protocol/clash.go b/internal/protocol/clash.go index a8a8faf..37a8d89 100644 --- a/internal/protocol/clash.go +++ b/internal/protocol/clash.go @@ -28,7 +28,8 @@ func GenerateClashWithTemplate(templateName string, servers []model.Server, user proxyNames := make([]string, 0, len(servers)) for _, s := range servers { conf := service.BuildNodeConfig(&s) - proxy := buildClashProxy(conf, user) + password := service.GenerateServerPassword(&s, &user) + proxy := buildClashProxy(conf, password) if proxy == nil { continue } @@ -52,7 +53,7 @@ func GenerateClashWithTemplate(templateName string, servers []model.Server, user return output, nil } -func buildClashProxy(conf service.NodeServerConfig, user model.User) map[string]any { +func buildClashProxy(conf service.NodeServerConfig, password string) map[string]any { switch conf.Protocol { case "shadowsocks": cipher, _ := conf.Cipher.(string) @@ -62,7 +63,7 @@ func buildClashProxy(conf service.NodeServerConfig, user model.User) map[string] "server": conf.RawHost, "port": conf.ServerPort, "cipher": cipher, - "password": user.UUID, + "password": password, } case "vmess": return map[string]any{ @@ -70,7 +71,7 @@ func buildClashProxy(conf service.NodeServerConfig, user model.User) map[string] "type": "vmess", "server": conf.RawHost, "port": conf.ServerPort, - "uuid": user.UUID, + "uuid": password, "alterId": 0, "cipher": "auto", "udp": true, @@ -81,7 +82,7 @@ func buildClashProxy(conf service.NodeServerConfig, user model.User) map[string] "type": "trojan", "server": conf.RawHost, "port": conf.ServerPort, - "password": user.UUID, + "password": password, "udp": true, } default: @@ -161,7 +162,8 @@ func generateClashFallback(servers []model.Server, user model.User) string { var proxyNames []string for _, s := range servers { conf := service.BuildNodeConfig(&s) - proxy := buildClashProxy(conf, user) + password := service.GenerateServerPassword(&s, &user) + proxy := buildClashProxy(conf, password) if proxy == nil { continue } @@ -174,7 +176,7 @@ func generateClashFallback(servers []model.Server, user model.User) string { conf.RawHost, conf.ServerPort, fmt.Sprint(proxy["cipher"]), - user.UUID, + fmt.Sprint(proxy["password"]), )) case "vmess": builder.WriteString(fmt.Sprintf( @@ -182,7 +184,7 @@ func generateClashFallback(servers []model.Server, user model.User) string { conf.Name, conf.RawHost, conf.ServerPort, - user.UUID, + fmt.Sprint(proxy["uuid"]), )) case "trojan": builder.WriteString(fmt.Sprintf( @@ -190,7 +192,7 @@ func generateClashFallback(servers []model.Server, user model.User) string { conf.Name, conf.RawHost, conf.ServerPort, - user.UUID, + fmt.Sprint(proxy["password"]), )) } proxyNames = append(proxyNames, fmt.Sprintf("\"%s\"", conf.Name)) diff --git a/internal/protocol/link.go b/internal/protocol/link.go index 1a103c7..43fb5a3 100644 --- a/internal/protocol/link.go +++ b/internal/protocol/link.go @@ -14,7 +14,8 @@ func GenerateGeneralLinks(servers []model.Server, user model.User) string { var links []string for _, s := range servers { conf := service.BuildNodeConfig(&s) - link := BuildLink(conf, user) + password := service.GenerateServerPassword(&s, &user) + link := BuildLink(conf, password) if link != "" { links = append(links, link) } @@ -22,36 +23,35 @@ func GenerateGeneralLinks(servers []model.Server, user model.User) string { return strings.Join(links, "\n") } -func BuildLink(c service.NodeServerConfig, user model.User) string { +func BuildLink(c service.NodeServerConfig, password string) string { switch c.Protocol { case "shadowsocks": - return buildShadowsocks(c, user) + return buildShadowsocks(c, password) case "vmess": - return buildVmess(c, user) + return buildVmess(c, password) case "vless": - return buildVless(c, user) + return buildVless(c, password) case "trojan": - return buildTrojan(c, user) + return buildTrojan(c, password) case "hysteria", "hysteria2": - return buildHysteria(c, user) + return buildHysteria(c, password) case "tuic": - return buildTuic(c, user) + return buildTuic(c, password) case "anytls": - return buildAnyTLS(c, user) + return buildAnyTLS(c, password) case "socks": - return buildSocks(c, user) + return buildSocks(c, password) case "http": - return buildHttp(c, user) + return buildHttp(c, password) } return "" } -func buildShadowsocks(c service.NodeServerConfig, user model.User) string { +func buildShadowsocks(c service.NodeServerConfig, password string) string { cipher := toString(c.Cipher) - password := user.UUID userInfo := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", cipher, password))) userInfo = strings.TrimRight(strings.ReplaceAll(strings.ReplaceAll(userInfo, "+", "-"), "/", "_"), "=") - + link := fmt.Sprintf("ss://%s@%s:%d", userInfo, wrapIPv6(c.RawHost), c.ServerPort) params := url.Values{} if plugin := toString(c.Plugin); plugin != "" { @@ -64,13 +64,13 @@ func buildShadowsocks(c service.NodeServerConfig, user model.User) string { return link + "#" + url.PathEscape(c.Name) } -func buildVmess(c service.NodeServerConfig, user model.User) string { +func buildVmess(c service.NodeServerConfig, password string) string { m := map[string]any{ "v": "2", "ps": c.Name, "add": c.RawHost, "port": fmt.Sprintf("%d", c.ServerPort), - "id": user.UUID, + "id": password, "aid": "0", "net": toString(c.Network), "type": "none", @@ -81,7 +81,7 @@ func buildVmess(c service.NodeServerConfig, user model.User) string { if c.Tls != nil && toInt(c.Tls) > 0 { m["tls"] = "tls" } - + if settings, ok := c.NetworkSettings.(map[string]any); ok { switch toString(c.Network) { case "ws": @@ -96,7 +96,7 @@ func buildVmess(c service.NodeServerConfig, user model.User) string { m["host"] = toString(settings["host"]) } } - + if tlsSettings, ok := c.TlsSettings.(map[string]any); ok { m["sni"] = toString(tlsSettings["server_name"]) } @@ -105,13 +105,13 @@ func buildVmess(c service.NodeServerConfig, user model.User) string { return "vmess://" + base64.StdEncoding.EncodeToString(b) } -func buildVless(c service.NodeServerConfig, user model.User) string { +func buildVless(c service.NodeServerConfig, password string) string { params := url.Values{} params.Set("encryption", "none") if c.Flow != nil { params.Set("flow", toString(c.Flow)) } - + security := "none" switch toInt(c.Tls) { case 1: @@ -132,7 +132,7 @@ func buildVless(c service.NodeServerConfig, user model.User) string { } params.Set("security", security) params.Set("type", toString(c.Network)) - + if settings, ok := c.NetworkSettings.(map[string]any); ok { switch toString(c.Network) { case "ws": @@ -145,10 +145,10 @@ func buildVless(c service.NodeServerConfig, user model.User) string { } } - return fmt.Sprintf("vless://%s@%s:%d?%s#%s", user.UUID, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) + return fmt.Sprintf("vless://%s@%s:%d?%s#%s", password, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } -func buildTrojan(c service.NodeServerConfig, user model.User) string { +func buildTrojan(c service.NodeServerConfig, password string) string { params := url.Values{} security := "tls" if toInt(c.Tls) == 2 { @@ -164,7 +164,7 @@ func buildTrojan(c service.NodeServerConfig, user model.User) string { } } params.Set("security", security) - + if settings, ok := c.NetworkSettings.(map[string]any); ok { switch toString(c.Network) { case "ws": @@ -176,51 +176,55 @@ func buildTrojan(c service.NodeServerConfig, user model.User) string { } } - return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", user.UUID, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) + return fmt.Sprintf("trojan://%s@%s:%d?%s#%s", password, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } -func buildHysteria(c service.NodeServerConfig, user model.User) string { +func buildHysteria(c service.NodeServerConfig, password string) string { params := url.Values{} params.Set("sni", toString(c.ServerName)) - + if c.Protocol == "hysteria2" || toInt(c.Version) == 2 { if obfs := toString(c.Obfs); obfs != "" { params.Set("obfs", "salamander") params.Set("obfs-password", toString(c.ObfsPassword)) } - return fmt.Sprintf("hysteria2://%s@%s:%d?%s#%s", user.UUID, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) + return fmt.Sprintf("hysteria2://%s@%s:%d?%s#%s", password, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } - + params.Set("protocol", "udp") - params.Set("auth", user.UUID) - if c.UpMbps != nil { params.Set("upmbps", fmt.Sprintf("%v", c.UpMbps)) } - if c.DownMbps != nil { params.Set("downmbps", fmt.Sprintf("%v", c.DownMbps)) } - + params.Set("auth", password) + if c.UpMbps != nil { + params.Set("upmbps", fmt.Sprintf("%v", c.UpMbps)) + } + if c.DownMbps != nil { + params.Set("downmbps", fmt.Sprintf("%v", c.DownMbps)) + } + return fmt.Sprintf("hysteria://%s:%d?%s#%s", wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } -func buildTuic(c service.NodeServerConfig, user model.User) string { +func buildTuic(c service.NodeServerConfig, password string) string { params := url.Values{} params.Set("sni", toString(c.ServerName)) params.Set("congestion_control", toString(c.CongestionControl)) params.Set("udp-relay-mode", "native") - - return fmt.Sprintf("tuic://%s:%s@%s:%d?%s#%s", user.UUID, user.UUID, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) + + return fmt.Sprintf("tuic://%s:%s@%s:%d?%s#%s", password, password, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } -func buildAnyTLS(c service.NodeServerConfig, user model.User) string { +func buildAnyTLS(c service.NodeServerConfig, password string) string { params := url.Values{} params.Set("sni", toString(c.ServerName)) - return fmt.Sprintf("anytls://%s@%s:%d?%s#%s", user.UUID, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) + return fmt.Sprintf("anytls://%s@%s:%d?%s#%s", password, wrapIPv6(c.RawHost), c.ServerPort, params.Encode(), url.PathEscape(c.Name)) } -func buildSocks(c service.NodeServerConfig, user model.User) string { - auth := base64.StdEncoding.EncodeToString([]byte(user.UUID + ":" + user.UUID)) +func buildSocks(c service.NodeServerConfig, password string) string { + auth := base64.StdEncoding.EncodeToString([]byte(password + ":" + password)) return fmt.Sprintf("socks://%s@%s:%d#%s", auth, wrapIPv6(c.RawHost), c.ServerPort, url.PathEscape(c.Name)) } -func buildHttp(c service.NodeServerConfig, user model.User) string { - auth := base64.StdEncoding.EncodeToString([]byte(user.UUID + ":" + user.UUID)) +func buildHttp(c service.NodeServerConfig, password string) string { + auth := base64.StdEncoding.EncodeToString([]byte(password + ":" + password)) link := fmt.Sprintf("http://%s@%s:%d", auth, wrapIPv6(c.RawHost), c.ServerPort) if toInt(c.Tls) > 0 { params := url.Values{} @@ -238,16 +242,23 @@ func wrapIPv6(host string) string { } func toString(v any) string { - if s, ok := v.(string); ok { return s } - if v == nil { return "" } + if s, ok := v.(string); ok { + return s + } + if v == nil { + return "" + } return fmt.Sprintf("%v", v) } func toInt(v any) int { switch typed := v.(type) { - case int: return typed - case int64: return int(typed) - case float64: return int(typed) + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) } return 0 } diff --git a/internal/protocol/singbox.go b/internal/protocol/singbox.go index 0308c88..d8f0133 100644 --- a/internal/protocol/singbox.go +++ b/internal/protocol/singbox.go @@ -25,7 +25,8 @@ func GenerateSingBox(servers []model.Server, user model.User) (string, error) { proxyTags := make([]string, 0, len(servers)) for _, s := range servers { conf := service.BuildNodeConfig(&s) - outbound := buildSingBoxOutbound(conf, user) + password := service.GenerateServerPassword(&s, &user) + outbound := buildSingBoxOutbound(conf, password) if outbound == nil { continue } @@ -59,7 +60,7 @@ func GenerateSingBox(servers []model.Server, user model.User) (string, error) { return string(data), nil } -func buildSingBoxOutbound(conf service.NodeServerConfig, user model.User) map[string]any { +func buildSingBoxOutbound(conf service.NodeServerConfig, password string) map[string]any { outbound := map[string]any{ "tag": conf.Name, "server": conf.RawHost, @@ -70,14 +71,14 @@ func buildSingBoxOutbound(conf service.NodeServerConfig, user model.User) map[st case "shadowsocks": outbound["type"] = "shadowsocks" outbound["method"] = conf.Cipher - outbound["password"] = user.UUID + outbound["password"] = password case "vmess": outbound["type"] = "vmess" - outbound["uuid"] = user.UUID + outbound["uuid"] = password outbound["security"] = "auto" case "trojan": outbound["type"] = "trojan" - outbound["password"] = user.UUID + outbound["password"] = password default: return nil } @@ -91,6 +92,7 @@ func generateSingBoxFallback(servers []model.Server, user model.User) (string, e for _, s := range servers { conf := service.BuildNodeConfig(&s) + password := service.GenerateServerPassword(&s, &user) outbound := map[string]interface{}{ "tag": conf.Name, "server": conf.RawHost, @@ -101,14 +103,14 @@ func generateSingBoxFallback(servers []model.Server, user model.User) (string, e case "shadowsocks": outbound["type"] = "shadowsocks" outbound["method"] = conf.Cipher - outbound["password"] = user.UUID + outbound["password"] = password case "vmess": outbound["type"] = "vmess" - outbound["uuid"] = user.UUID + outbound["uuid"] = password outbound["security"] = "auto" case "trojan": outbound["type"] = "trojan" - outbound["password"] = user.UUID + outbound["password"] = password default: continue } diff --git a/internal/service/email.go b/internal/service/email.go index a1651aa..5a5ba6d 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -57,11 +57,15 @@ func LoadEmailConfig() EmailConfig { func (cfg EmailConfig) DebugConfig() map[string]any { return map[string]any{ - "driver": "smtp", - "host": cfg.Host, - "port": cfg.Port, - "encryption": cfg.Encryption, - "username": cfg.Username, + "driver": "smtp", + "host": cfg.Host, + "port": cfg.Port, + "encryption": cfg.Encryption, + "username": cfg.Username, + "from": map[string]any{ + "address": cfg.SenderAddress(), + "name": cfg.FromName, + }, "from_address": cfg.SenderAddress(), "from_name": cfg.FromName, } diff --git a/internal/service/node.go b/internal/service/node.go index 76f1bb8..569b2ab 100644 --- a/internal/service/node.go +++ b/internal/service/node.go @@ -21,17 +21,35 @@ var serverTypeAliases = map[string]string{ } var validServerTypes = map[string]struct{}{ - "anytls": {}, - "http": {}, - "hysteria": {}, - "mieru": {}, - "naive": {}, + "anytls": {}, + "http": {}, + "hysteria": {}, + "mieru": {}, + "naive": {}, "shadowsocks": {}, - "socks": {}, - "trojan": {}, - "tuic": {}, - "vless": {}, - "vmess": {}, + "socks": {}, + "trojan": {}, + "tuic": {}, + "vless": {}, + "vmess": {}, +} + +var shadowsocks2022CipherConfigs = map[string]struct { + serverKeySize int + userKeySize int +}{ + "2022-blake3-aes-128-gcm": { + serverKeySize: 16, + userKeySize: 16, + }, + "2022-blake3-aes-256-gcm": { + serverKeySize: 32, + userKeySize: 32, + }, + "2022-blake3-chacha20-poly1305": { + serverKeySize: 32, + userKeySize: 32, + }, } type NodeUser struct { @@ -47,41 +65,41 @@ type NodeBaseConfig struct { } type NodeServerConfig struct { - Name string `json:"-"` - Protocol string `json:"protocol"` - RawHost string `json:"-"` - ListenIP string `json:"listen_ip"` - ServerPort int `json:"server_port"` - Network any `json:"network"` - NetworkSettings any `json:"networkSettings"` - Cipher any `json:"cipher,omitempty"` - Plugin any `json:"plugin,omitempty"` - PluginOpts any `json:"plugin_opts,omitempty"` - ServerKey any `json:"server_key,omitempty"` - Host any `json:"host,omitempty"` - ServerName any `json:"server_name,omitempty"` - Tls any `json:"tls,omitempty"` - TlsSettings any `json:"tls_settings,omitempty"` - Flow any `json:"flow,omitempty"` - Multiplex any `json:"multiplex,omitempty"` - UpMbps any `json:"up_mbps,omitempty"` - DownMbps any `json:"down_mbps,omitempty"` - Version any `json:"version,omitempty"` - Obfs any `json:"obfs,omitempty"` - ObfsPassword any `json:"obfs-password,omitempty"` - CongestionControl any `json:"congestion_control,omitempty"` - AuthTimeout any `json:"auth_timeout,omitempty"` - ZeroRTTHandshake any `json:"zero_rtt_handshake,omitempty"` - Heartbeat any `json:"heartbeat,omitempty"` - PaddingScheme any `json:"padding_scheme,omitempty"` - Transport any `json:"transport,omitempty"` - TrafficPattern any `json:"traffic_pattern,omitempty"` - Decryption any `json:"decryption,omitempty"` - Routes []model.ServerRoute `json:"routes,omitempty"` - CustomOutbounds any `json:"custom_outbounds,omitempty"` - CustomRoutes any `json:"custom_routes,omitempty"` - CertConfig any `json:"cert_config,omitempty"` - BaseConfig NodeBaseConfig `json:"base_config"` + Name string `json:"-"` + Protocol string `json:"protocol"` + RawHost string `json:"-"` + ListenIP string `json:"listen_ip"` + ServerPort int `json:"server_port"` + Network any `json:"network"` + NetworkSettings any `json:"networkSettings"` + Cipher any `json:"cipher,omitempty"` + Plugin any `json:"plugin,omitempty"` + PluginOpts any `json:"plugin_opts,omitempty"` + ServerKey any `json:"server_key,omitempty"` + Host any `json:"host,omitempty"` + ServerName any `json:"server_name,omitempty"` + Tls any `json:"tls,omitempty"` + TlsSettings any `json:"tls_settings,omitempty"` + Flow any `json:"flow,omitempty"` + Multiplex any `json:"multiplex,omitempty"` + UpMbps any `json:"up_mbps,omitempty"` + DownMbps any `json:"down_mbps,omitempty"` + Version any `json:"version,omitempty"` + Obfs any `json:"obfs,omitempty"` + ObfsPassword any `json:"obfs-password,omitempty"` + CongestionControl any `json:"congestion_control,omitempty"` + AuthTimeout any `json:"auth_timeout,omitempty"` + ZeroRTTHandshake any `json:"zero_rtt_handshake,omitempty"` + Heartbeat any `json:"heartbeat,omitempty"` + PaddingScheme any `json:"padding_scheme,omitempty"` + Transport any `json:"transport,omitempty"` + TrafficPattern any `json:"traffic_pattern,omitempty"` + Decryption any `json:"decryption,omitempty"` + Routes []model.ServerRoute `json:"routes,omitempty"` + CustomOutbounds any `json:"custom_outbounds,omitempty"` + CustomRoutes any `json:"custom_routes,omitempty"` + CertConfig any `json:"cert_config,omitempty"` + BaseConfig NodeBaseConfig `json:"base_config"` } func NormalizeServerType(serverType string) string { @@ -165,6 +183,31 @@ func AvailableServersForUser(user *model.User) ([]model.Server, error) { return filtered, nil } +func GenerateServerPassword(node *model.Server, user *model.User) string { + if node == nil || user == nil { + return "" + } + + if NormalizeServerType(node.Type) != "shadowsocks" { + return user.UUID + } + + settings := parseObject(node.ProtocolSettings) + cipher := getMapString(settings, "cipher") + config, ok := shadowsocks2022CipherConfigs[cipher] + if !ok { + return user.UUID + } + + createdAt := resolveServerCreatedAt(node) + serverKey := serverKey(createdAt, config.serverKeySize) + if serverKey == "" { + return user.UUID + } + + return serverKey + ":" + uuidPrefixBase64(user.UUID, config.userKeySize) +} + func CurrentRate(server *model.Server) float64 { if !server.RateTimeEnable { return float64(server.Rate) @@ -322,7 +365,10 @@ func serverKey(createdAt *time.Time, size int) string { if createdAt == nil { return "" } - sum := md5.Sum([]byte(strconv.FormatInt(createdAt.Unix(), 10))) + + // Match XBoard's Helper::getServerKey(created_at, size): + // base64_encode(substr(md5($timestamp), 0, size)) + sum := md5.Sum([]byte(createdAt.Format("2006-01-02 15:04:05"))) hex := fmt.Sprintf("%x", sum) if size > len(hex) { size = len(hex) @@ -330,6 +376,38 @@ func serverKey(createdAt *time.Time, size int) string { return base64.StdEncoding.EncodeToString([]byte(hex[:size])) } +func resolveServerCreatedAt(node *model.Server) *time.Time { + if node == nil { + return nil + } + + if node.ParentID == nil || *node.ParentID <= 0 { + return node.CreatedAt + } + + var parent struct { + CreatedAt *time.Time `gorm:"column:created_at"` + } + if err := database.DB.Model(&model.Server{}). + Select("created_at"). + Where("id = ?", *node.ParentID). + First(&parent).Error; err == nil && parent.CreatedAt != nil { + return parent.CreatedAt + } + + return node.CreatedAt +} + +func uuidPrefixBase64(uuid string, size int) string { + if size <= 0 { + return "" + } + if size > len(uuid) { + size = len(uuid) + } + return base64.StdEncoding.EncodeToString([]byte(uuid[:size])) +} + func parseIntSlice(raw *string) []int { if raw == nil || strings.TrimSpace(*raw) == "" { return nil