package frontend import ( "autolibrary.top/webserver/config" "autolibrary.top/webserver/frontend/handlers/errors_handler" "autolibrary.top/webserver/utils" "context" "fmt" "github.com/gin-gonic/gin" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "regexp" "strings" ) var hashedAsset = regexp.MustCompile(`-[a-zA-Z0-9_-]{8,}\.[a-z0-9]+$`) func createProxy(targetURL string) gin.HandlerFunc { target, _ := url.Parse(targetURL) proxy := httputil.NewSingleHostReverseProxy(target) proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) { log.Printf("[W] backend proxy: %v", err) w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte(`{"code":502,"msg":"Backend service is unavailable","data":null}`)) } return func(c *gin.Context) { c.Request.URL.Path = c.Param("path"); proxy.ServeHTTP(c.Writer, c.Request) } } // ResolveStaticPath also accepts the legacy public configuration if a sibling // production build exists. The configured path can be absolute or relative. func ResolveStaticPath(configured string) (string, error) { path := filepath.Clean(configured) if filepath.Base(path) == "public" { sibling := filepath.Join(filepath.Dir(path), "dist") if _, err := os.Stat(filepath.Join(sibling, "index.html")); err == nil { path = sibling } } if _, err := os.Stat(filepath.Join(path, "index.html")); err != nil { return "", fmt.Errorf("frontend build missing at %s: run npm --prefix frontend run build: %w", path, err) } return path, nil } func NewRouter(cfg *config.Config) (*gin.Engine, error) { staticPath, err := ResolveStaticPath(cfg.Frontend.Static.Path) if err != nil { return nil, err } entry, err := os.ReadFile(filepath.Join(staticPath, "index.html")) if err != nil { return nil, err } gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) pages := errors_handler.New(entry) r.Use(pages.APIAccessMiddleware()) r.Use(func(c *gin.Context) { c.Header("X-Content-Type-Options", "nosniff") if strings.HasPrefix(c.Request.URL.Path, "/assets/") { if hashedAsset.MatchString(c.Request.URL.Path) { c.Header("Cache-Control", "public, max-age=31536000, immutable") } else { c.Header("Cache-Control", "public, max-age=3600") } } else { c.Header("Cache-Control", "no-cache") } c.Next() }) r.Static("/assets", filepath.Join(staticPath, "assets")) backendHost := cfg.Backend.Host if backendHost == "0.0.0.0" || backendHost == "::" || backendHost == "" { backendHost = "127.0.0.1" } r.Any("/api/*path", createProxy("http://"+net.JoinHostPort(backendHost, cfg.Backend.Port))) serve := func(c *gin.Context) { c.Data(http.StatusOK, "text/html; charset=utf-8", entry) } for _, path := range []string{"/", "/downloads", "/marketplace", "/marketplace/upload", "/marketplace/profile", "/marketplace/admin"} { r.GET(path, serve) r.HEAD(path, serve) } for _, method := range []string{http.MethodGet, http.MethodHead} { r.Handle(method, "/changelog", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/downloads") }) } pages.RegisterGetErrorRoutes(r, []int{400, 401, 403, 404, 500, 502, 503}) r.NoRoute(func(c *gin.Context) { if c.Request.URL.Path == "/manuals" || strings.HasPrefix(c.Request.URL.Path, "/manuals/") { c.Redirect(http.StatusMovedPermanently, "https://manuals.autolibrary.kenanzhu.com") return } if strings.HasPrefix(c.Request.URL.Path, "/api/") { c.JSON(http.StatusNotFound, gin.H{"code": 404, "msg": "API route not found", "data": nil}) return } pages.GetErrorsPage(c, http.StatusNotFound) }) return r, nil } func StartFrontend() error { return StartFrontendContext(context.Background()) } func StartFrontendContext(ctx context.Context) error { cfg := config.GetConfig() router, err := NewRouter(cfg) if err != nil { return err } address := net.JoinHostPort(cfg.Frontend.Host, cfg.Frontend.Port) log.Printf("[I] frontend server is running on: http://%s", address) return utils.ServeHTTP(ctx, address, router) }