package server import ( "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" ) type Options struct { UIServeDir string BigscreenDir string TemplateDir string StaticDir string EnableCORS bool AuthSecret string } func NewRouter(opts Options) *gin.Engine { r := gin.New() r.Use(gin.Logger()) r.Use(gin.Recovery()) if opts.EnableCORS { r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() }) } if strings.TrimSpace(opts.StaticDir) != "" { r.Static("/static", opts.StaticDir) } // Do not render legacy templates; keep core frontend under /ui api := r.Group("/api") { api.GET("/health", handleHealth) api.GET("/system/status", handleSystemStatus) api.GET("/stations", handleStations) api.GET("/data", handleData) api.GET("/forecast", handleForecast) api.GET("/forecast/perf", handleForecastPerf) api.GET("/radar/times", handleRadarTimes) api.GET("/radar/tiles_at", handleRadarTilesAt) api.GET("/rain/times", handleRainTimes) api.GET("/rain/tiles_at", handleRainTilesAt) } hasUI := strings.TrimSpace(opts.UIServeDir) != "" if hasUI { // Serve built Angular assets under /ui for static files r.Static("/ui", opts.UIServeDir) // Serve Angular index.html at root r.GET("/", func(c *gin.Context) { c.File(filepath.Join(opts.UIServeDir, "index.html")) }) } hasBigscreen := strings.TrimSpace(opts.BigscreenDir) != "" var bigscreenIndex string if hasBigscreen { bigscreenDir := filepath.Clean(opts.BigscreenDir) bigscreenIndex = filepath.Join(bigscreenDir, "index.html") // auth guard requireAuth := func(c *gin.Context) bool { if strings.TrimSpace(opts.AuthSecret) == "" { // no secret configured -> deny by default c.Redirect(http.StatusFound, "/admin/login") c.Abort() return false } cookie, err := c.Cookie("core_session") if err != nil || cookie == "" { c.Redirect(http.StatusFound, "/admin/login") c.Abort() return false } if _, ok := validateSession(cookie, opts.AuthSecret); !ok { c.Redirect(http.StatusFound, "/admin/login") c.Abort() return false } return true } serveBigscreenIndex := func(c *gin.Context) { if !requireAuth(c) { return } c.File(bigscreenIndex) } r.GET("/bigscreen", serveBigscreenIndex) r.GET("/bigscreen/*filepath", func(c *gin.Context) { if !requireAuth(c) { return } rel := strings.TrimPrefix(c.Param("filepath"), "/") if rel == "" { serveBigscreenIndex(c) return } full := filepath.Join(bigscreenDir, filepath.FromSlash(rel)) if !strings.HasPrefix(full, bigscreenDir+string(os.PathSeparator)) && full != bigscreenDir { c.AbortWithStatus(http.StatusBadRequest) return } if info, err := os.Stat(full); err == nil && !info.IsDir() { c.File(full) return } serveBigscreenIndex(c) }) } // Admin login routes r.GET("/admin/login", func(c *gin.Context) { if strings.TrimSpace(opts.TemplateDir) != "" { c.File(filepath.Join(opts.TemplateDir, "login.html")) return } c.String(http.StatusOK, "login page not configured") }) r.POST("/admin/login", handleLogin(opts)) r.GET("/admin/logout", handleLogout(opts)) // Optional SPA fallback: serve index.html for non-API, non-static routes r.NoRoute(func(c *gin.Context) { p := c.Request.URL.Path if strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/static/") { c.AbortWithStatus(http.StatusNotFound) return } if hasBigscreen && strings.HasPrefix(p, "/bigscreen") { c.File(bigscreenIndex) return } if hasUI { c.File(filepath.Join(opts.UIServeDir, "index.html")) return } c.AbortWithStatus(http.StatusNotFound) }) return r } // validateSession bridges to auth package without importing at top to avoid circular style concerns. func validateSession(token, secret string) (string, bool) { // local shim to avoid exposing auth from here return parseToken(token, []byte(secret)) }