@@ -0,0 +1,316 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"software-download-center/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestUpdateInfoContainsOnlyFullInstallerAndMSIX(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0.exe", "installer")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_2.0.7.0_x64.msix", "msix")
|
||||
writeTestFile(t, downloadsDir, "winui.appinstaller", "appinstaller")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe", "light")
|
||||
writeTestFile(t, filepath.Join(downloadsDir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
writeTestFile(t, publicDir, "update-info.json", `{
|
||||
"baselineVersion": "2.0.6.0",
|
||||
"minIncrementalVersion": "2.0.6.0",
|
||||
"lightInstaller": {"fileName":"light.exe"},
|
||||
"packages": [{"id":"external-tools"}],
|
||||
"incrementals": [{"id":"delta"}],
|
||||
"messages": {"static":"kept"}
|
||||
}`)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/update-info.json", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected update-info 200, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertAbsent(t, body, "baselineVersion", "minIncrementalVersion", "lightInstaller", "packages", "incrementals", "requiredForFull", "modules")
|
||||
if body["latestVersion"] != "2.0.7.0" {
|
||||
t.Fatalf("expected latestVersion 2.0.7.0, got %#v", body["latestVersion"])
|
||||
}
|
||||
|
||||
latest := body["latest"].(map[string]interface{})
|
||||
assertAbsent(t, latest, "lightInstaller", "incrementals", "packages")
|
||||
fullInstaller := latest["fullInstaller"].(map[string]interface{})
|
||||
if fullInstaller["fileName"] != "YMhut_Box_WinUI_Setup_2.0.7.0.exe" {
|
||||
t.Fatalf("unexpected full installer: %#v", fullInstaller)
|
||||
}
|
||||
msix := latest["msix"].(map[string]interface{})
|
||||
if msix["fileName"] != "YMhut_Box_WinUI_2.0.7.0_x64.msix" {
|
||||
t.Fatalf("unexpected msix: %#v", msix)
|
||||
}
|
||||
appInstaller := latest["appInstaller"].(map[string]interface{})
|
||||
if appInstaller["fileName"] != "winui.appinstaller" {
|
||||
t.Fatalf("unexpected appinstaller: %#v", appInstaller)
|
||||
}
|
||||
|
||||
text := recorder.Body.String()
|
||||
for _, forbidden := range []string{"_Light.exe", "YMhut_Box_Update_", "external-tools", "baselineVersion", "incrementals"} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("update-info leaked removed field/content %q: %s", forbidden, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedDistributionRoutesReturnGone(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
for _, route := range []string{
|
||||
"/modules.json",
|
||||
"/api/modules",
|
||||
"/package-manifest.json",
|
||||
"/incremental-manifest.json",
|
||||
"/api/packages/manifest",
|
||||
"/api/incrementals",
|
||||
"/api/incrementals/manifest",
|
||||
"/api/packages/download/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
"/api/incrementals/download/YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip",
|
||||
"/packages/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
"/tool-packages/YMhut_Box_Tools_2.0.6.0.zip",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusGone {
|
||||
t.Fatalf("expected %s to return 410, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalUpdateInfoRoutesServeDirectlyAndLegacyRoutesRedirect(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
for _, route := range []string{"/update-info.json", "/update-info", "/api/update-info"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected %s to return 200, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
manifestRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(manifestRecorder, httptest.NewRequest("GET", "/manifest.json", nil))
|
||||
if manifestRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected /manifest.json compatibility response 200, got %d: %s", manifestRecorder.Code, manifestRecorder.Body.String())
|
||||
}
|
||||
if manifestRecorder.Header().Get("Deprecation") != "true" {
|
||||
t.Fatalf("expected /manifest.json to include Deprecation header")
|
||||
}
|
||||
if manifestRecorder.Header().Get("X-YMhut-Canonical-Manifest") != "/update-info.json" {
|
||||
t.Fatalf("expected /manifest.json to point at /update-info.json")
|
||||
}
|
||||
|
||||
for _, route := range []string{"/latest-version.json", "/api/releases/latest", "/latest.json"} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("expected %s to redirect with 301, got %d", route, recorder.Code)
|
||||
}
|
||||
if location := recorder.Header().Get("Location"); location != "/update-info.json" {
|
||||
t.Fatalf("expected %s Location /update-info.json, got %q", route, location)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadsRouteAllowsOnlySingleInstallerArtifact(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0.exe", "installer")
|
||||
writeTestFile(t, downloadsDir, "YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe", "light")
|
||||
writeTestFile(t, filepath.Join(downloadsDir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
okRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(okRecorder, httptest.NewRequest("GET", "/downloads/YMhut_Box_WinUI_Setup_2.0.7.0.exe", nil))
|
||||
if okRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected full installer download 200, got %d: %s", okRecorder.Code, okRecorder.Body.String())
|
||||
}
|
||||
if okRecorder.Body.String() != "installer" {
|
||||
t.Fatalf("unexpected installer body: %q", okRecorder.Body.String())
|
||||
}
|
||||
|
||||
for _, route := range []string{
|
||||
"/downloads/YMhut_Box_WinUI_Setup_2.0.7.0_Light.exe",
|
||||
"/downloads/incremental/YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip",
|
||||
"/downloads/..%2Fsecret.exe",
|
||||
"/downloads/subdir/file.exe",
|
||||
} {
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", route, nil))
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected %s to be forbidden, got %d: %s", route, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAPIRequiresAuthentication(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/api/admin/releases/files", nil))
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 for unauthenticated admin API, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "UNAUTHORIZED") {
|
||||
t.Fatalf("expected structured unauthorized response, got %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPageShowsUnauthorizedShellWithoutAuth(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rootDir := t.TempDir()
|
||||
publicDir := filepath.Join(rootDir, "public")
|
||||
downloadsDir := filepath.Join(publicDir, "downloads")
|
||||
viewsDir := filepath.Join(rootDir, "views")
|
||||
mustMkdirAll(t, downloadsDir)
|
||||
mustMkdirAll(t, viewsDir)
|
||||
writeTemplateFiles(t, viewsDir)
|
||||
|
||||
router := buildTestRouter(t, rootDir)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest("GET", "/admin/", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected admin shell 200, got %d: %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "未授权") {
|
||||
t.Fatalf("expected admin shell fallback to show unauthorized copy, got %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePackageFileNamesSupportsNewAndLegacyInstallerNames(t *testing.T) {
|
||||
if comparePackageFileNames("YMhut_Box_WinUI_Setup_2.0.7.0.exe", "YMhut_Box_Setup_2.0.6.0.exe") <= 0 {
|
||||
t.Fatal("expected WinUI 2.0.7.0 installer to be newer than legacy 2.0.6.0 installer")
|
||||
}
|
||||
if comparePackageFileNames("YMhut_Box_Setup_2.0.7.0.exe", "YMhut_Box_WinUI_Setup_2.0.7.0.exe") == 0 {
|
||||
t.Fatal("expected stable tie-breaker for same-version installer names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductsInfoRecognizesMSIXAndSkipsIncrementalSubdirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestFile(t, dir, "YMhut_Box_WinUI_2.0.7.0_x64.msix", "msix")
|
||||
writeTestFile(t, filepath.Join(dir, "incremental"), "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip", "incremental")
|
||||
|
||||
products := utils.GetProductsInfo(dir, utils.NewLogger())
|
||||
if len(products) == 0 {
|
||||
t.Fatalf("expected MSIX package to be detected")
|
||||
}
|
||||
for _, releases := range products {
|
||||
for _, release := range releases {
|
||||
if release.FileName == "YMhut_Box_Update_2.0.6.0_to_2.0.7.0.zip" {
|
||||
t.Fatalf("incremental package from subdirectory should not be listed as product: %#v", products)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildTestRouter(t *testing.T, rootDir string) *gin.Engine {
|
||||
t.Helper()
|
||||
oldWorkingDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(rootDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = os.Chdir(oldWorkingDir)
|
||||
})
|
||||
|
||||
router := gin.New()
|
||||
RegisterRoutes(router, utils.NewLogger())
|
||||
return router
|
||||
}
|
||||
|
||||
func writeTemplateFiles(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, dir, "index.html", "{{.pageTitle}}")
|
||||
writeTestFile(t, dir, "404.html", "{{.title}}")
|
||||
writeTestFile(t, dir, "500.html", "{{.title}}")
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, dir string, name string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMkdirAll(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertAbsent(t *testing.T, body map[string]interface{}, keys ...string) {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
if _, ok := body[key]; ok {
|
||||
t.Fatalf("expected field %s to be absent in %#v", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
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: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||
Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。",
|
||||
ThemeColor: "#166534",
|
||||
Tags: []string{"桌面工具", "效率", "多平台"},
|
||||
},
|
||||
"YmhutBox": {
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>`,
|
||||
Description: "多功能工具箱,整合系统、网络、图像和日常效率工具。",
|
||||
ThemeColor: "#166534",
|
||||
Tags: []string{"桌面工具", "效率", "多平台"},
|
||||
},
|
||||
"弓福小筑": {
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>`,
|
||||
Description: "轻量入口应用,提供站点访问与定制下载入口。",
|
||||
ThemeColor: "#b45309",
|
||||
Tags: []string{"轻量", "入口", "桌面"},
|
||||
},
|
||||
}
|
||||
|
||||
defaultMeta = ProductMeta{
|
||||
Icon: `<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2v20"/><path d="M2 12h20"/></svg>`,
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user