diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 82595b5..9fb8634 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -4,8 +4,6 @@ import ( "log" "net/http" "os" - "path/filepath" - "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -32,20 +30,16 @@ func main() { } }() - // Workdir + // Initialize filesystem workdir := os.Getenv("NOVAMD_WORKDIR") if workdir == "" { workdir = "./data" } - fs := filesystem.New(workdir) - // User service + // Initialize user service userService := user.NewUserService(database, fs) - - // Admin user - _, err = userService.SetupAdminUser() - if err != nil { + if _, err := userService.SetupAdminUser(); err != nil { log.Fatal(err) } @@ -54,44 +48,26 @@ func main() { r.Use(middleware.Logger) r.Use(middleware.Recoverer) - // Set up API routes + // API routes r.Route("/api/v1", func(r chi.Router) { api.SetupRoutes(r, database, fs) }) - // Set up static file server with path validation + // Static file serving staticPath := os.Getenv("NOVAMD_STATIC_PATH") if staticPath == "" { staticPath = "../frontend/dist" } - fileServer := http.FileServer(http.Dir(staticPath)) - r.Get( - "/*", - func(w http.ResponseWriter, r *http.Request) { - requestedPath := r.URL.Path - fullPath := filepath.Join(staticPath, requestedPath) - cleanPath := filepath.Clean(fullPath) - - if !strings.HasPrefix(cleanPath, staticPath) { - http.Error(w, "Invalid path", http.StatusBadRequest) - return - } - - _, err = os.Stat(cleanPath) - if os.IsNotExist(err) { - http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) - return - } - http.StripPrefix("/", fileServer).ServeHTTP(w, r) - }, - ) + // Handle all other routes with static file server + r.Get("/*", api.NewStaticHandler(staticPath).ServeHTTP) // Start server port := os.Getenv("NOVAMD_PORT") if port == "" { port = "8080" } + log.Printf("Server starting on port %s", port) log.Fatal(http.ListenAndServe(":"+port, r)) } diff --git a/backend/internal/api/static_handler.go b/backend/internal/api/static_handler.go new file mode 100644 index 0000000..971b35d --- /dev/null +++ b/backend/internal/api/static_handler.go @@ -0,0 +1,70 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// StaticHandler serves static files with support for SPA routing and pre-compressed files +type StaticHandler struct { + staticPath string +} + +func NewStaticHandler(staticPath string) *StaticHandler { + return &StaticHandler{ + staticPath: staticPath, + } +} + +func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Get the requested path + requestedPath := r.URL.Path + fullPath := filepath.Join(h.staticPath, requestedPath) + cleanPath := filepath.Clean(fullPath) + + // Security check to prevent directory traversal + if !strings.HasPrefix(cleanPath, h.staticPath) { + http.Error(w, "Invalid path", http.StatusBadRequest) + return + } + + // Set cache headers for assets + if strings.HasPrefix(requestedPath, "/assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000") // 1 year + } + + // Check if file exists (not counting .gz files) + stat, err := os.Stat(cleanPath) + if err != nil || stat.IsDir() { + // Serve index.html for SPA routing + indexPath := filepath.Join(h.staticPath, "index.html") + http.ServeFile(w, r, indexPath) + return + } + + // Check for pre-compressed version + if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { + gzPath := cleanPath + ".gz" + if _, err := os.Stat(gzPath); err == nil { + w.Header().Set("Content-Encoding", "gzip") + + // Set proper content type based on original file + switch filepath.Ext(cleanPath) { + case ".js": + w.Header().Set("Content-Type", "application/javascript") + case ".css": + w.Header().Set("Content-Type", "text/css") + case ".html": + w.Header().Set("Content-Type", "text/html") + } + + http.ServeFile(w, r, gzPath) + return + } + } + + // Serve original file + http.ServeFile(w, r, cleanPath) +}