@@ -0,0 +1,115 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ConfigCache 配置缓存
|
||||
type ConfigCache struct {
|
||||
mu sync.RWMutex
|
||||
toolStatus map[string]interface{}
|
||||
updateInfo map[string]interface{}
|
||||
mediaTypes map[string]interface{}
|
||||
}
|
||||
|
||||
var configCache = &ConfigCache{}
|
||||
|
||||
// LoadConfig 加载配置文件
|
||||
func LoadConfig(filename string) (map[string]interface{}, error) {
|
||||
filePath := filepath.Join("public", filename)
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var config map[string]interface{}
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveConfig 保存配置文件并更新缓存
|
||||
func SaveConfig(filename string, config map[string]interface{}) error {
|
||||
filePath := filepath.Join("public", filename)
|
||||
|
||||
// 转换为 JSON
|
||||
jsonData, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 保存文件
|
||||
if err := os.WriteFile(filePath, jsonData, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
configCache.mu.Lock()
|
||||
defer configCache.mu.Unlock()
|
||||
|
||||
switch filename {
|
||||
case "tool-status.json":
|
||||
configCache.toolStatus = config
|
||||
case "update-info.json":
|
||||
configCache.updateInfo = config
|
||||
case "media-types.json":
|
||||
configCache.mediaTypes = config
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedConfig 获取缓存的配置
|
||||
func GetCachedConfig(filename string) (map[string]interface{}, bool) {
|
||||
configCache.mu.RLock()
|
||||
defer configCache.mu.RUnlock()
|
||||
|
||||
switch filename {
|
||||
case "tool-status.json":
|
||||
if configCache.toolStatus != nil {
|
||||
return configCache.toolStatus, true
|
||||
}
|
||||
case "update-info.json":
|
||||
if configCache.updateInfo != nil {
|
||||
return configCache.updateInfo, true
|
||||
}
|
||||
case "media-types.json":
|
||||
if configCache.mediaTypes != nil {
|
||||
return configCache.mediaTypes, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ReloadConfig 重新加载配置
|
||||
func ReloadConfig(filename string) error {
|
||||
config, err := LoadConfig(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return SaveConfig(filename, config)
|
||||
}
|
||||
|
||||
// InitConfigCache 初始化配置缓存
|
||||
func InitConfigCache() error {
|
||||
files := []string{"tool-status.json", "update-info.json", "media-types.json"}
|
||||
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(filepath.Join("public", file)); err == nil {
|
||||
config, err := LoadConfig(file)
|
||||
if err == nil {
|
||||
SaveConfig(file, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestYmhutWinUIInstallerAliasesMatchLegacyInstaller(t *testing.T) {
|
||||
cases := []string{
|
||||
"YMhut_Box_WinUI_Setup_2.0.6.0.exe",
|
||||
"YMhut_Box_Setup_2.0.5.exe",
|
||||
}
|
||||
|
||||
for _, fileName := range cases {
|
||||
productName, version, _, ok := parsePackageFileName(fileName)
|
||||
if !ok {
|
||||
t.Fatalf("expected %s to be parsed", fileName)
|
||||
}
|
||||
if !IsSameProduct(productName, "YMhut Box") {
|
||||
t.Fatalf("expected %q from %s to match YMhut Box", productName, fileName)
|
||||
}
|
||||
if version == "" {
|
||||
t.Fatalf("expected version from %s", fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var jwtSecret = []byte("your-secret-key-change-in-production") // 生产环境应该使用环境变量
|
||||
|
||||
// Claims JWT 声明
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT Token
|
||||
func GenerateToken(userID uint, username string, isAdmin bool) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
IsAdmin: isAdmin,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
NotBefore: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(jwtSecret)
|
||||
}
|
||||
|
||||
// ParseToken 解析 JWT Token
|
||||
func ParseToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return jwtSecret, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("无效的 token")
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ANSI 颜色代码
|
||||
const (
|
||||
ColorReset = "\033[0m"
|
||||
ColorBright = "\033[1m"
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorYellow = "\033[33m"
|
||||
ColorBlue = "\033[34m"
|
||||
ColorCyan = "\033[36m"
|
||||
ColorGray = "\033[90m"
|
||||
)
|
||||
|
||||
// Logger 日志记录器
|
||||
type Logger struct{}
|
||||
|
||||
// NewLogger 创建新的日志记录器
|
||||
func NewLogger() *Logger {
|
||||
return &Logger{}
|
||||
}
|
||||
|
||||
// log 格式化日志条目
|
||||
func (l *Logger) log(level, color, message string) {
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
levelTag := fmt.Sprintf("%s%-7s%s", color, level, ColorReset)
|
||||
|
||||
// 格式化时间戳
|
||||
formattedTime := fmt.Sprintf("%s%s%s", ColorGray, timestamp, ColorReset)
|
||||
|
||||
// 根据级别选择输出方式
|
||||
if level == "ERROR" {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s] %s\n", formattedTime, levelTag, message)
|
||||
} else {
|
||||
fmt.Printf("%s [%s] %s\n", formattedTime, levelTag, message)
|
||||
}
|
||||
}
|
||||
|
||||
// Info 绿色 "INFO" 级别日志
|
||||
func (l *Logger) Info(message string) {
|
||||
l.log("INFO", ColorGreen, message)
|
||||
}
|
||||
|
||||
// Warn 黄色 "WARN" 级别日志
|
||||
func (l *Logger) Warn(message string) {
|
||||
l.log("WARN", ColorYellow, message)
|
||||
}
|
||||
|
||||
// Error 红色 "ERROR" 级别日志
|
||||
func (l *Logger) Error(message string) {
|
||||
l.log("ERROR", ColorBright+ColorRed, message)
|
||||
}
|
||||
|
||||
// System 青色 "SYSTEM" 级别日志
|
||||
func (l *Logger) System(message string) {
|
||||
l.log("SYSTEM", ColorCyan, message)
|
||||
}
|
||||
|
||||
// HTTP 蓝色 "HTTP" 级别日志
|
||||
func (l *Logger) HTTP(message string) {
|
||||
l.log("HTTP", ColorBlue, message)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OSInfo 操作系统信息
|
||||
type OSInfo struct {
|
||||
OS string // 操作系统类型: windows, linux, darwin
|
||||
Arch string // 架构: amd64, arm64, 386
|
||||
IsCGO bool // 是否支持 CGO
|
||||
DataDir string // 数据目录路径
|
||||
}
|
||||
|
||||
var currentOS *OSInfo
|
||||
|
||||
// DetectOS 检测操作系统环境
|
||||
func DetectOS() *OSInfo {
|
||||
if currentOS != nil {
|
||||
return currentOS
|
||||
}
|
||||
|
||||
osType := runtime.GOOS
|
||||
arch := runtime.GOARCH
|
||||
|
||||
// 检测 CGO 支持
|
||||
// 注意:CGO 支持在编译时确定,运行时无法准确检测
|
||||
// 这里通过检查环境变量或尝试使用 SQLite 来判断
|
||||
isCGO := true
|
||||
// 如果设置了 CGO_ENABLED=0,则不支持 CGO
|
||||
if cgoEnv := os.Getenv("CGO_ENABLED"); cgoEnv == "0" {
|
||||
isCGO = false
|
||||
}
|
||||
|
||||
// 确定数据目录
|
||||
dataDir := "data"
|
||||
if osType == "windows" {
|
||||
// Windows 使用相对路径
|
||||
dataDir = "data"
|
||||
} else {
|
||||
// Linux/macOS 使用相对路径
|
||||
dataDir = "data"
|
||||
}
|
||||
|
||||
currentOS = &OSInfo{
|
||||
OS: normalizeOS(osType),
|
||||
Arch: normalizeArch(arch),
|
||||
IsCGO: isCGO,
|
||||
DataDir: dataDir,
|
||||
}
|
||||
|
||||
return currentOS
|
||||
}
|
||||
|
||||
// normalizeOS 标准化操作系统名称
|
||||
func normalizeOS(os string) string {
|
||||
os = strings.ToLower(os)
|
||||
switch os {
|
||||
case "windows":
|
||||
return "windows"
|
||||
case "linux":
|
||||
return "linux"
|
||||
case "darwin":
|
||||
return "darwin"
|
||||
case "freebsd", "openbsd", "netbsd":
|
||||
return "unix"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeArch 标准化架构名称
|
||||
func normalizeArch(arch string) string {
|
||||
arch = strings.ToLower(arch)
|
||||
switch arch {
|
||||
case "amd64", "x86_64":
|
||||
return "amd64"
|
||||
case "386", "i386", "i686":
|
||||
return "386"
|
||||
case "arm64", "aarch64":
|
||||
return "arm64"
|
||||
case "arm":
|
||||
return "arm"
|
||||
default:
|
||||
return arch
|
||||
}
|
||||
}
|
||||
|
||||
// GetOSInfo 获取操作系统信息
|
||||
func GetOSInfo() *OSInfo {
|
||||
return DetectOS()
|
||||
}
|
||||
|
||||
// IsWindows 判断是否为 Windows
|
||||
func IsWindows() bool {
|
||||
return DetectOS().OS == "windows"
|
||||
}
|
||||
|
||||
// IsLinux 判断是否为 Linux
|
||||
func IsLinux() bool {
|
||||
return DetectOS().OS == "linux"
|
||||
}
|
||||
|
||||
// IsDarwin 判断是否为 macOS
|
||||
func IsDarwin() bool {
|
||||
return DetectOS().OS == "darwin"
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPasswordTooShort = errors.New("密码长度至少为8个字符")
|
||||
ErrPasswordNoUpper = errors.New("密码必须包含至少一个大写字母")
|
||||
ErrPasswordNoLower = errors.New("密码必须包含至少一个小写字母")
|
||||
ErrPasswordNoDigit = errors.New("密码必须包含至少一个数字")
|
||||
ErrPasswordNoSpecial = errors.New("密码必须包含至少一个特殊字符")
|
||||
ErrPasswordCommon = errors.New("密码不能是常见弱密码")
|
||||
ErrPasswordSameChars = errors.New("密码不能全部是相同字符")
|
||||
)
|
||||
|
||||
// 常见弱密码列表
|
||||
var commonPasswords = []string{
|
||||
"password", "12345678", "123456789", "1234567890",
|
||||
"qwerty", "abc123", "password123", "admin123",
|
||||
"123456", "1234567", "12345", "1234",
|
||||
"admin", "root", "user", "test",
|
||||
}
|
||||
|
||||
// ValidatePasswordStrength 验证密码强度
|
||||
func ValidatePasswordStrength(password string) error {
|
||||
// 检查长度
|
||||
if len(password) < 8 {
|
||||
return ErrPasswordTooShort
|
||||
}
|
||||
|
||||
// 检查是否全部相同字符
|
||||
allSame := true
|
||||
for i := 1; i < len(password); i++ {
|
||||
if password[i] != password[0] {
|
||||
allSame = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allSame {
|
||||
return ErrPasswordSameChars
|
||||
}
|
||||
|
||||
// 检查是否包含大写字母
|
||||
hasUpper := false
|
||||
// 检查是否包含小写字母
|
||||
hasLower := false
|
||||
// 检查是否包含数字
|
||||
hasDigit := false
|
||||
// 检查是否包含特殊字符
|
||||
hasSpecial := false
|
||||
|
||||
for _, char := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(char):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(char):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(char):
|
||||
hasDigit = true
|
||||
case unicode.IsPunct(char) || unicode.IsSymbol(char):
|
||||
hasSpecial = true
|
||||
}
|
||||
}
|
||||
|
||||
if !hasUpper {
|
||||
return ErrPasswordNoUpper
|
||||
}
|
||||
if !hasLower {
|
||||
return ErrPasswordNoLower
|
||||
}
|
||||
if !hasDigit {
|
||||
return ErrPasswordNoDigit
|
||||
}
|
||||
if !hasSpecial {
|
||||
return ErrPasswordNoSpecial
|
||||
}
|
||||
|
||||
// 检查是否是常见弱密码
|
||||
lowerPassword := regexp.MustCompile(`[^a-z]`).ReplaceAllString(password, "")
|
||||
for _, common := range commonPasswords {
|
||||
if lowerPassword == common {
|
||||
return ErrPasswordCommon
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashPassword 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(bytes), err
|
||||
}
|
||||
|
||||
// CheckPassword 验证密码
|
||||
func CheckPassword(password, hash string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Version string `json:"version"`
|
||||
Extension string `json:"extension"`
|
||||
FileName string `json:"fileName"`
|
||||
DownloadPath string `json:"downloadPath"`
|
||||
Size string `json:"size"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
UpdateDate string `json:"updateDate"`
|
||||
UpdateTime string `json:"updateTime"`
|
||||
}
|
||||
|
||||
type ProductsInfo map[string][]FileInfo
|
||||
|
||||
var (
|
||||
supportedPackageExtOrder = []string{
|
||||
".tar.gz",
|
||||
".appimage",
|
||||
".msi",
|
||||
".exe",
|
||||
".zip",
|
||||
".pkg",
|
||||
".dmg",
|
||||
".apk",
|
||||
".deb",
|
||||
".rpm",
|
||||
}
|
||||
versionPattern = regexp.MustCompile(`(?i)(?:^|[ _-])v?(\d+(?:\.\d+){1,4})(?:$|[ _-])`)
|
||||
)
|
||||
|
||||
func FormatBytes(bytes int64, precision int) string {
|
||||
if bytes == 0 {
|
||||
return "0 B"
|
||||
}
|
||||
|
||||
units := []string{"B", "KB", "MB", "GB", "TB"}
|
||||
if precision == 0 {
|
||||
precision = 2
|
||||
}
|
||||
|
||||
bytesFloat := float64(bytes)
|
||||
if bytesFloat < 0 {
|
||||
bytesFloat = 0
|
||||
}
|
||||
|
||||
pow := 0
|
||||
if bytesFloat > 0 {
|
||||
pow = int(float64(len(fmt.Sprintf("%.0f", bytesFloat))-1) / 3.321928)
|
||||
}
|
||||
if pow >= len(units) {
|
||||
pow = len(units) - 1
|
||||
}
|
||||
|
||||
divisor := int64(1)
|
||||
for i := 0; i < pow; i++ {
|
||||
divisor *= 1024
|
||||
}
|
||||
|
||||
converted := bytesFloat / float64(divisor)
|
||||
return fmt.Sprintf("%.*f %s", precision, converted, units[pow])
|
||||
}
|
||||
|
||||
func GetProductsInfo(downloadDir string, logger *Logger) ProductsInfo {
|
||||
products := make(ProductsInfo)
|
||||
|
||||
if _, err := os.Stat(downloadDir); os.IsNotExist(err) {
|
||||
logger.Error(fmt.Sprintf("读取下载目录失败: %s - 错误: %s", downloadDir, err.Error()))
|
||||
return nil
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(downloadDir)
|
||||
if err != nil {
|
||||
logger.Error(fmt.Sprintf("读取下载目录失败: %s - 错误: %s", downloadDir, err.Error()))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
fileName := entry.Name()
|
||||
productName, version, ext, ok := parsePackageFileName(fileName)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(downloadDir, fileName)
|
||||
stat, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Warn(fmt.Sprintf("获取文件统计信息失败: %s - 错误: %s", fileName, err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
fileInfoData := FileInfo{
|
||||
Version: version,
|
||||
Extension: strings.TrimPrefix(ext, "."),
|
||||
FileName: fileName,
|
||||
DownloadPath: "/downloads/" + fileName,
|
||||
Size: FormatBytes(stat.Size(), 2),
|
||||
SizeBytes: stat.Size(),
|
||||
UpdateDate: stat.ModTime().Format("2006-01-02"),
|
||||
UpdateTime: stat.ModTime().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
products[productName] = append(products[productName], fileInfoData)
|
||||
}
|
||||
|
||||
for productName := range products {
|
||||
sort.Slice(products[productName], func(i, j int) bool {
|
||||
v1 := products[productName][i].Version
|
||||
v2 := products[productName][j].Version
|
||||
if compareVersions(v1, v2) == 0 {
|
||||
return products[productName][i].UpdateTime > products[productName][j].UpdateTime
|
||||
}
|
||||
return compareVersions(v1, v2) > 0
|
||||
})
|
||||
}
|
||||
|
||||
return products
|
||||
}
|
||||
|
||||
func GetLatestProductRelease(products ProductsInfo, preferredProduct string) (string, *FileInfo) {
|
||||
if len(products) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var selectedProduct string
|
||||
var selectedRelease *FileInfo
|
||||
|
||||
if preferredProduct != "" {
|
||||
for productName, releases := range products {
|
||||
if !IsSameProduct(productName, preferredProduct) || len(releases) == 0 {
|
||||
continue
|
||||
}
|
||||
candidate := releases[0]
|
||||
if selectedRelease == nil || isNewerRelease(candidate, *selectedRelease) {
|
||||
selectedProduct = productName
|
||||
selectedRelease = &candidate
|
||||
}
|
||||
}
|
||||
if selectedRelease != nil {
|
||||
return selectedProduct, selectedRelease
|
||||
}
|
||||
}
|
||||
|
||||
for productName, releases := range products {
|
||||
if len(releases) == 0 {
|
||||
continue
|
||||
}
|
||||
candidate := releases[0]
|
||||
if selectedRelease == nil || isNewerRelease(candidate, *selectedRelease) {
|
||||
selectedProduct = productName
|
||||
selectedRelease = &candidate
|
||||
}
|
||||
}
|
||||
|
||||
return selectedProduct, selectedRelease
|
||||
}
|
||||
|
||||
func IsSameProduct(productName string, preferredProduct string) bool {
|
||||
return productAliasKey(productName) == productAliasKey(preferredProduct)
|
||||
}
|
||||
|
||||
func productAliasKey(name string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
key = strings.NewReplacer(" ", "", "_", "", "-", "", ".", "").Replace(key)
|
||||
switch key {
|
||||
case "ymhutbox", "ymhut":
|
||||
return "ymhutbox"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func isNewerRelease(candidate FileInfo, current FileInfo) bool {
|
||||
versionCompare := compareVersions(candidate.Version, current.Version)
|
||||
if versionCompare != 0 {
|
||||
return versionCompare > 0
|
||||
}
|
||||
return candidate.UpdateTime > current.UpdateTime
|
||||
}
|
||||
|
||||
func parsePackageFileName(fileName string) (string, string, string, bool) {
|
||||
ext, stem, ok := splitPackageExtension(fileName)
|
||||
if !ok {
|
||||
return "", "", "", false
|
||||
}
|
||||
|
||||
version := extractVersion(stem)
|
||||
productName := normalizeProductName(stem, version)
|
||||
if productName == "" {
|
||||
productName = stem
|
||||
}
|
||||
|
||||
if version == "" {
|
||||
version = "未标注"
|
||||
}
|
||||
|
||||
return productName, version, ext, true
|
||||
}
|
||||
|
||||
func splitPackageExtension(fileName string) (string, string, bool) {
|
||||
lower := strings.ToLower(fileName)
|
||||
for _, ext := range supportedPackageExtOrder {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return ext, fileName[:len(fileName)-len(ext)], true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func extractVersion(stem string) string {
|
||||
matches := versionPattern.FindStringSubmatch(stem)
|
||||
if len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeProductName(stem string, version string) string {
|
||||
name := stem
|
||||
if version != "" {
|
||||
name = versionPattern.ReplaceAllString(name, " ")
|
||||
}
|
||||
|
||||
replacements := []string{
|
||||
"setup", "installer", "install", "release", "portable",
|
||||
"windows", "window", "win", "linux", "android", "macos", "darwin",
|
||||
"x64", "x86", "arm64", "amd64", "universal", "desktop",
|
||||
}
|
||||
|
||||
for _, token := range replacements {
|
||||
re := regexp.MustCompile(`(?i)(^|[ _-])` + regexp.QuoteMeta(token) + `($|[ _-])`)
|
||||
name = re.ReplaceAllString(name, " ")
|
||||
}
|
||||
|
||||
name = strings.NewReplacer("_", " ", "-", " ").Replace(name)
|
||||
name = strings.Join(strings.Fields(name), " ")
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func compareVersions(v1, v2 string) int {
|
||||
if v1 == "未标注" && v2 == "未标注" {
|
||||
return 0
|
||||
}
|
||||
if v1 == "未标注" {
|
||||
return -1
|
||||
}
|
||||
if v2 == "未标注" {
|
||||
return 1
|
||||
}
|
||||
|
||||
parts1 := strings.Split(v1, ".")
|
||||
parts2 := strings.Split(v2, ".")
|
||||
|
||||
maxLen := len(parts1)
|
||||
if len(parts2) > maxLen {
|
||||
maxLen = len(parts2)
|
||||
}
|
||||
|
||||
for i := 0; i < maxLen; i++ {
|
||||
num1 := 0
|
||||
num2 := 0
|
||||
if i < len(parts1) {
|
||||
fmt.Sscanf(parts1[i], "%d", &num1)
|
||||
}
|
||||
if i < len(parts2) {
|
||||
fmt.Sscanf(parts2[i], "%d", &num2)
|
||||
}
|
||||
|
||||
if num1 > num2 {
|
||||
return 1
|
||||
}
|
||||
if num1 < num2 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func FileExists(filePath string) bool {
|
||||
_, err := os.Stat(filePath)
|
||||
return !os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func ReadJSONFile(filePath string) (map[string]interface{}, error) {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func GetMimeType(filePath string) string {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
mimeTypes := map[string]string{
|
||||
".json": "application/json; charset=utf-8",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".exe": "application/octet-stream",
|
||||
".zip": "application/zip",
|
||||
".pkg": "application/octet-stream",
|
||||
".dmg": "application/x-apple-diskimage",
|
||||
".msi": "application/x-msi",
|
||||
".apk": "application/vnd.android.package-archive",
|
||||
".deb": "application/vnd.debian.binary-package",
|
||||
".rpm": "application/x-rpm",
|
||||
}
|
||||
|
||||
if mime, ok := mimeTypes[ext]; ok {
|
||||
return mime
|
||||
}
|
||||
|
||||
if strings.HasSuffix(strings.ToLower(filePath), ".tar.gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetLatestProductReleaseUsesYmhutBoxAliases(t *testing.T) {
|
||||
products := ProductsInfo{
|
||||
"YmhutBox": {
|
||||
{
|
||||
Version: "2.0.0",
|
||||
FileName: "YmhutBox Setup 2.0.0.exe",
|
||||
DownloadPath: "/downloads/YmhutBox Setup 2.0.0.exe",
|
||||
UpdateTime: "2026-04-28 04:48:25",
|
||||
},
|
||||
},
|
||||
"YMhut Box": {
|
||||
{
|
||||
Version: "2.0.1",
|
||||
FileName: "YMhut_Box_Setup_2.0.1.exe",
|
||||
DownloadPath: "/downloads/YMhut_Box_Setup_2.0.1.exe",
|
||||
UpdateTime: "2026-04-30 17:27:21",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
productName, release := GetLatestProductRelease(products, "YMhut Box")
|
||||
if release == nil {
|
||||
t.Fatal("expected a release")
|
||||
}
|
||||
if productName != "YMhut Box" {
|
||||
t.Fatalf("expected YMhut Box alias to win, got %q", productName)
|
||||
}
|
||||
if release.Version != "2.0.1" {
|
||||
t.Fatalf("expected version 2.0.1, got %q", release.Version)
|
||||
}
|
||||
if release.FileName != "YMhut_Box_Setup_2.0.1.exe" {
|
||||
t.Fatalf("expected new installer, got %q", release.FileName)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user