Add server components
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:28:09 +08:00
parent 7ecc6a8923
commit 079ee4eaeb
168 changed files with 37475 additions and 0 deletions
+115
View File
@@ -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)
}
}
}
+52
View File
@@ -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")
}
+68
View File
@@ -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)
}
+109
View File
@@ -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"
}
+104
View File
@@ -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
}
+351
View File
@@ -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"
}
+38
View File
@@ -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)
}
}