Files
Website/frontend/frontend.go

118 lines
4.0 KiB
Go

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)
}