package config import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "net/http" "os" "path/filepath" "reflect" "strings" "software-download-center/database" "software-download-center/handlers" "software-download-center/middleware" "software-download-center/utils" "github.com/gin-gonic/gin" ) type ProductMeta struct { Icon string Description string ThemeColor string Tags []string } var ( productMeta = map[string]ProductMeta{ "YMhut Box": { Icon: ``, Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。", ThemeColor: "#166534", Tags: []string{"桌面工具", "效率", "多平台"}, }, "YmhutBox": { Icon: ``, Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。", ThemeColor: "#166534", Tags: []string{"桌面工具", "效率", "多平台"}, }, "弓福小筑": { Icon: ``, Description: "轻量入口应用,提供站点访问与定制下载入口。", ThemeColor: "#b45309", Tags: []string{"轻量", "入口", "桌面"}, }, } defaultMeta = ProductMeta{ Icon: ``, Description: "自动从 downloads 目录识别出的安装包分组。", ThemeColor: "#57534e", Tags: []string{"自动识别", "安装包", "下载"}, } ) func RegisterRoutes(r *gin.Engine, logger *utils.Logger) { logger.System("\n开始注册路由...\n") rootDir, err := os.Getwd() if err != nil { logger.Error(fmt.Sprintf("获取工作目录失败: %s", err.Error())) return } publicDir := filepath.Join(rootDir, "public") viewsDir := filepath.Join(rootDir, "views") downloadsDir := filepath.Join(publicDir, "downloads") r.SetFuncMap(template.FuncMap{ "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "marshalJSON": func(v interface{}) string { data, _ := json.Marshal(v) return string(data) }, "slice": func(slice interface{}, start int, args ...int) interface{} { v := reflect.ValueOf(slice) if v.Kind() != reflect.Slice { return slice } end := v.Len() if len(args) > 0 { end = args[0] } if start < 0 { start = 0 } if end > v.Len() { end = v.Len() } if start >= end { return reflect.MakeSlice(v.Type(), 0, 0).Interface() } return v.Slice(start, end).Interface() }, }) r.LoadHTMLGlob(filepath.Join(viewsDir, "*.html")) r.GET("/", func(c *gin.Context) { products := utils.GetProductsInfo(downloadsDir, logger) errorMessage := "" if products == nil { errorMessage = "无法读取 downloads 目录,请检查目录权限和文件配置。" } else if len(products) == 0 { errorMessage = "downloads 目录中暂时没有可识别的安装包。" } c.HTML(http.StatusOK, "index.html", gin.H{ "products": products, "productMeta": productMeta, "defaultMeta": defaultMeta, "pageTitle": "YMhut 下载中心", "errorMessage": errorMessage, }) }) logger.Info("注册路由成功 [GET] /") registerDynamicUpdateInfoRoutes(r, logger, publicDir, downloadsDir) registerReleaseAPIRoutes(r, logger, publicDir, downloadsDir) jsonRoutes := []struct { path string file string cacheControl string }{ {"/tool-status.json", "tool-status.json", "public, max-age=600"}, {"/tool-status", "tool-status.json", "public, max-age=600"}, {"/media-types.json", "media-types.json", "public, max-age=3600"}, {"/media-types", "media-types.json", "public, max-age=3600"}, {"/plugins", "plugins.json", "public, max-age=3600"}, {"/plugins.json", "plugins.json", "public, max-age=3600"}, {"/modules", "modules.json", "public, max-age=600"}, {"/modules.json", "modules.json", "public, max-age=600"}, } for _, route := range jsonRoutes { filePath := filepath.Join(publicDir, route.file) fp := filePath cc := route.cacheControl path := route.path fileName := route.file if utils.FileExists(filePath) { r.GET(path, func(c *gin.Context) { if cached, ok := utils.GetCachedConfig(fileName); ok { c.Header("Content-Type", "application/json; charset=utf-8") c.Header("Cache-Control", cc) c.JSON(http.StatusOK, cached) return } data, err := utils.ReadJSONFile(fp) if err != nil { logger.Error(fmt.Sprintf("读取 JSON 文件失败: %s - %s", fp, err.Error())) c.JSON(http.StatusInternalServerError, gin.H{"error": "文件读取失败"}) return } utils.SaveConfig(fileName, data) c.Header("Content-Type", "application/json; charset=utf-8") c.Header("Cache-Control", cc) c.JSON(http.StatusOK, data) }) logger.Info(fmt.Sprintf("注册路由成功 [GET] %s", path)) } else { logger.Warn(fmt.Sprintf("跳过路由,文件不存在: %s", path)) } } fileRoutes := []struct { path string file string cacheControl string headers map[string]string }{ {"/lang/zh-CN.json", "lang/zh-CN.json", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="zh-CN.json"`}}, {"/lang/en-US.json", "lang/en-US.json", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="en-US.json"`}}, {"/fonts/MeiGanShouXieTi-2.ttf", "fonts/MeiGanShouXieTi-2.ttf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="MeiGanShouXieTi-2.ttf"`}}, {"/fonts/QianTuBiFengShouXieTi-2.ttf", "fonts/QianTuBiFengShouXieTi-2.ttf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="QianTuBiFengShouXieTi-2.ttf"`}}, {"/fonts/YOzBS-2.otf", "fonts/YOzBS-2.otf", "public, max-age=86400", map[string]string{"Content-Disposition": `attachment; filename="YOzBS-2.otf"`}}, {"/favicon.ico", "img/favicon.png", "public, max-age=604800", nil}, } for _, route := range fileRoutes { filePath := filepath.Join(publicDir, route.file) fp := filePath cc := route.cacheControl hdrs := route.headers path := route.path if utils.FileExists(filePath) { r.GET(path, func(c *gin.Context) { mimeType := utils.GetMimeType(fp) c.Header("Content-Type", mimeType) c.Header("Cache-Control", cc) if hdrs != nil { for k, v := range hdrs { c.Header(k, v) } } c.File(fp) }) logger.Info(fmt.Sprintf("注册路由成功 [GET] %s", path)) } else { logger.Warn(fmt.Sprintf("跳过路由,文件不存在: %s", path)) } } r.Static("/css", filepath.Join(publicDir, "css")) r.Static("/img", filepath.Join(publicDir, "img")) r.GET("/downloads/:filename", func(c *gin.Context) { filename := c.Param("filename") if strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") { logger.Warn(fmt.Sprintf("拒绝下载请求: %s (IP: %s)", filename, c.ClientIP())) c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"}) return } filePath := filepath.Join(downloadsDir, filename) resolvedPath, err := filepath.Abs(filePath) if err != nil { logger.Error(fmt.Sprintf("解析文件路径失败: %s", err.Error())) c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"}) return } normalizedDir, _ := filepath.Abs(downloadsDir) if !strings.HasPrefix(resolvedPath, normalizedDir) { logger.Warn(fmt.Sprintf("下载路径越界: %s (IP: %s)", filename, c.ClientIP())) c.JSON(http.StatusForbidden, gin.H{"error": "禁止访问"}) return } if !utils.FileExists(filePath) { logger.Error(fmt.Sprintf("下载失败,文件不存在: %s", filename)) c.HTML(http.StatusNotFound, "404.html", gin.H{ "title": "文件未找到", "path": c.Request.URL.String(), }) return } logger.Info(fmt.Sprintf("下载成功: %s (IP: %s)", filename, c.ClientIP())) c.File(filePath) }) r.GET("/admin", middleware.AuthMiddleware(), func(c *gin.Context) { c.HTML(http.StatusOK, "admin.html", gin.H{"title": "后台管理"}) }) r.GET("/admin/login", func(c *gin.Context) { if token, _ := c.Cookie("token"); token != "" { c.Redirect(http.StatusFound, "/admin") return } c.HTML(http.StatusOK, "login.html", gin.H{"title": "登录"}) }) r.GET("/admin/register", func(c *gin.Context) { if token, _ := c.Cookie("token"); token != "" { c.Redirect(http.StatusFound, "/admin") return } c.HTML(http.StatusOK, "register.html", gin.H{"title": "注册"}) }) r.GET("/admin/settings", middleware.AuthMiddleware(), middleware.AdminMiddleware(), func(c *gin.Context) { c.HTML(http.StatusOK, "settings.html", gin.H{"title": "系统设置"}) }) r.GET("/admin/install", func(c *gin.Context) { if database.IsDBInitialized() { c.Redirect(http.StatusFound, "/admin/login") return } c.HTML(http.StatusOK, "install.html", gin.H{"title": "数据库配置"}) }) r.GET("/admin/install/status", handlers.CheckInstallStatus) r.POST("/admin/install/database", handlers.InstallDatabase) adminRoutes := r.Group("/admin") { adminRoutes.POST("/register", handlers.Register) adminRoutes.POST("/login", handlers.Login) adminRoutes.POST("/logout", handlers.Logout) adminRoutes.GET("/me", middleware.AuthMiddleware(), handlers.GetCurrentUser) adminAPI := adminRoutes.Group("/api") adminAPI.Use(middleware.AuthMiddleware(), middleware.AdminMiddleware()) { adminAPI.GET("/logs", handlers.GetLogs) adminAPI.GET("/routes", handlers.GetRoutes) adminAPI.POST("/routes", handlers.CreateRoute) adminAPI.PUT("/routes/:id", handlers.UpdateRoute) adminAPI.DELETE("/routes/:id", handlers.DeleteRoute) adminAPI.GET("/files", handlers.GetFiles) adminAPI.GET("/file", handlers.ReadFile) adminAPI.POST("/file", handlers.SaveFile) adminAPI.PUT("/config", handlers.UpdateJSONConfig) adminAPI.GET("/system", handlers.GetSystemInfo) adminAPI.GET("/database", handlers.GetDatabaseInfo) adminAPI.GET("/database/config", handlers.GetDatabaseConfig) adminAPI.POST("/database/config", handlers.UpdateDatabaseConfig) adminAPI.POST("/database/convert", handlers.ConvertDatabase) adminAPI.POST("/database/password", handlers.UpdateDatabasePassword) adminAPI.POST("/reload", handlers.ReloadRoutes) } } r.NoRoute(func(c *gin.Context) { fullURL := c.Request.URL.String() logger.Warn(fmt.Sprintf("404 Not Found - %s", fullURL)) c.HTML(http.StatusNotFound, "404.html", gin.H{ "title": "页面未找到", "path": fullURL, }) }) r.Use(gin.CustomRecovery(func(c *gin.Context, recovered interface{}) { logger.Error(fmt.Sprintf("500 Server Error - 路径: %s - 错误: %v", c.Request.URL.Path, recovered)) c.HTML(http.StatusInternalServerError, "500.html", gin.H{ "title": "服务器错误", "message": "服务器内部错误,请稍后重试。", }) c.Abort() })) logger.System("路由注册完成。\n") } func registerDynamicUpdateInfoRoutes( r *gin.Engine, logger *utils.Logger, publicDir string, downloadsDir string, ) { updateInfoPath := filepath.Join(publicDir, "update-info.json") for _, path := range []string{"/update-info.json", "/update-info"} { routePath := path r.GET(routePath, func(c *gin.Context) { payload := map[string]interface{}{} if utils.FileExists(updateInfoPath) { if data, err := utils.ReadJSONFile(updateInfoPath); err == nil { payload = data } } products := utils.GetProductsInfo(downloadsDir, logger) productName, latest := utils.GetLatestProductRelease(products, "YMhut Box") if latest != nil { baseURL := requestBaseURL(c) payload["app_version"] = latest.Version payload["download_url"] = baseURL + latest.DownloadPath payload["download_mirrors"] = []map[string]interface{}{ { "id": "primary", "name": "官方直连", "url": baseURL + latest.DownloadPath, "type": "direct", "sha256": sha256File(filepath.Join(downloadsDir, latest.FileName)), "enabled": true, }, } payload["detected_product"] = productName payload["detected_packages"] = products } c.Header("Content-Type", "application/json; charset=utf-8") c.Header("Cache-Control", "public, max-age=300") c.JSON(http.StatusOK, payload) }) logger.Info(fmt.Sprintf("注册动态更新信息路由 [GET] %s", routePath)) } } func requestBaseURL(c *gin.Context) string { scheme := c.GetHeader("X-Forwarded-Proto") if scheme == "" { if c.Request.TLS != nil { scheme = "https" } else { scheme = "http" } } return scheme + "://" + c.Request.Host } func registerReleaseAPIRoutes( r *gin.Engine, logger *utils.Logger, publicDir string, downloadsDir string, ) { r.GET("/api/releases", func(c *gin.Context) { c.JSON(http.StatusOK, buildReleaseManifest(c, logger, publicDir, downloadsDir)) }) r.GET("/api/modules", func(c *gin.Context) { manifest := buildReleaseManifest(c, logger, publicDir, downloadsDir) c.JSON(http.StatusOK, gin.H{ "manifest_version": manifest["manifest_version"], "modules": manifest["modules"], }) }) r.GET("/api/update-info", func(c *gin.Context) { c.JSON(http.StatusOK, buildReleaseManifest(c, logger, publicDir, downloadsDir)) }) } func buildReleaseManifest( c *gin.Context, logger *utils.Logger, publicDir string, downloadsDir string, ) map[string]interface{} { payload := map[string]interface{}{} updateInfoPath := filepath.Join(publicDir, "update-info.json") if utils.FileExists(updateInfoPath) { if data, err := utils.ReadJSONFile(updateInfoPath); err == nil { payload = data } } baseURL := requestBaseURL(c) products := utils.GetProductsInfo(downloadsDir, logger) packages := make([]map[string]interface{}, 0) for productName, releases := range products { for _, release := range releases { filePath := filepath.Join(downloadsDir, release.FileName) platform, arch := detectPackagePlatform(release.FileName, release.Extension) packages = append(packages, map[string]interface{}{ "id": packageID(productName, platform, arch, release.Version), "name": productName, "version": release.Version, "platform": platform, "arch": arch, "url": baseURL + release.DownloadPath, "sha256": sha256File(filePath), "size": release.SizeBytes, "required": utils.IsSameProduct(productName, "YMhut Box"), "enabled": true, "changelog": map[string]string{}, }) } } modulesPath := filepath.Join(publicDir, "modules.json") modules := []interface{}{} if utils.FileExists(modulesPath) { if data, err := utils.ReadJSONFile(modulesPath); err == nil { if raw, ok := data["modules"].([]interface{}); ok { modules = raw } } } payload["manifest_version"] = 2 payload["packages"] = packages payload["modules"] = modules payload["assets"] = []interface{}{} productName, latest := utils.GetLatestProductRelease(products, "YMhut Box") if latest != nil { latestURL := baseURL + latest.DownloadPath payload["app_version"] = latest.Version payload["download_url"] = latestURL payload["download_mirrors"] = []map[string]interface{}{ { "id": "primary", "name": "官方下载", "url": latestURL, "type": "direct", "sha256": sha256File(filepath.Join(downloadsDir, latest.FileName)), "enabled": true, }, } payload["detected_product"] = productName payload["detected_packages"] = products } return payload } func packageID(productName string, platform string, arch string, version string) string { value := strings.ToLower(productName + "-" + platform + "-" + arch + "-" + version) value = strings.NewReplacer(" ", "-", "_", "-").Replace(value) return value } func detectPackagePlatform(fileName string, ext string) (string, string) { lower := strings.ToLower(fileName) platform := "unknown" switch strings.ToLower(ext) { case "exe", "msi": platform = "windows" case "apk": platform = "android" case "dmg", "pkg": platform = "macos" case "deb", "rpm", "appimage", "tar.gz": platform = "linux" } arch := "x64" if strings.Contains(lower, "arm64") || strings.Contains(lower, "aarch64") { arch = "arm64" } else if strings.Contains(lower, "x86") && !strings.Contains(lower, "x64") { arch = "x86" } else if platform == "android" { arch = "universal" } return platform, arch } func sha256File(path string) string { file, err := os.Open(path) if err != nil { return "" } defer file.Close() hash := sha256.New() if _, err := io.Copy(hash, file); err != nil { return "" } return hex.EncodeToString(hash.Sum(nil)) }