package main import ( "embed" "flag" "fmt" "io" "log" "mime" "net/http" "path" "path/filepath" "strings" "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" "qn/feeds" "qn/notes" "qn/readlist" ) // Configuration holds the application configuration type Configuration struct { Port int DBPath string TestMode bool } func serveStaticFile(c *gin.Context, prefix string) error { cleanPath := path.Clean(c.Request.URL.Path) if cleanPath == "/" { cleanPath = "/index.html" } // Try to read the exact file first content, err := frontend.ReadFile(prefix + cleanPath) ext := strings.ToLower(filepath.Ext(cleanPath)) // If file not found OR the path has no extension (likely a route path), serve index.html if err != nil || ext == "" { content, err = frontend.ReadFile(prefix + "/index.html") if err != nil { return err } c.Header("Content-Type", "text/html; charset=utf-8") // Add security headers for HTML content c.Header("X-Content-Type-Options", "nosniff") c.Data(http.StatusOK, "text/html; charset=utf-8", content) return nil } // For actual files, set the correct MIME type var mimeType string switch ext { case ".js": mimeType = "application/javascript; charset=utf-8" case ".css": mimeType = "text/css; charset=utf-8" case ".html": mimeType = "text/html; charset=utf-8" case ".json": mimeType = "application/json; charset=utf-8" case ".png": mimeType = "image/png" case ".svg": mimeType = "image/svg+xml" default: mimeType = mime.TypeByExtension(ext) if mimeType == "" { // Try to detect content type from the content itself mimeType = http.DetectContentType(content) } } // Set security headers for all responses c.Header("X-Content-Type-Options", "nosniff") c.Data(http.StatusOK, mimeType, content) return nil } //go:embed frontend/build/* frontend/static/* var frontend embed.FS func main() { // Parse command line flags for configuration config := parseConfig() // Initialize database db, err := gorm.Open(sqlite.Open(config.DBPath), &gorm.Config{}) if err != nil { log.Fatal(err) } // Auto migrate the schema if err := db.AutoMigrate(¬es.Note{}, ¬es.NoteLink{}, &readlist.ReadLaterItem{}, &feeds.Feed{}, &feeds.Entry{}); err != nil { log.Fatal(err) } // Initialize services noteService := notes.NewService(db) noteHandler := notes.NewHandler(noteService) readlistService := readlist.NewService(db) readlistHandler := readlist.NewHandler(readlistService) feedsService := feeds.NewService(db) feedsHandler := feeds.NewHandler(feedsService) // Set Gin mode based on configuration if config.TestMode { gin.SetMode(gin.TestMode) // Disable Gin's console logging in test mode gin.DefaultWriter = io.Discard } // Create Gin router r := gin.New() // Use New() instead of Default() to configure middleware manually // Add recovery middleware r.Use(gin.Recovery()) // Add logger middleware only if not in test mode if !config.TestMode { r.Use(gin.Logger()) } // Trust only loopback addresses if err := r.SetTrustedProxies([]string{"127.0.0.1", "::1"}); err != nil { log.Fatal(err) } // API routes api := r.Group("/api") { noteHandler.RegisterRoutes(api) readlistHandler.RegisterRoutes(api) feedsHandler.RegisterRoutes(api) } // Serve frontend r.NoRoute(handleFrontend) // Start the server addr := fmt.Sprintf(":%d", config.Port) log.Printf("INFO: Server starting on http://localhost%s", addr) log.Fatal(r.Run(addr)) } func parseConfig() Configuration { // Default configuration config := Configuration{ Port: 3000, DBPath: "notes.db", TestMode: false, } // Parse command line flags flag.IntVar(&config.Port, "port", config.Port, "Port to listen on") flag.StringVar(&config.DBPath, "db", config.DBPath, "Path to SQLite database file") flag.BoolVar(&config.TestMode, "test", config.TestMode, "Run in test mode") flag.Parse() return config } func handleFrontend(c *gin.Context) { requestPath := c.Request.URL.Path // Check if the path is a direct file request (CSS, JS, etc.) if isStaticFileRequest(requestPath) { // Use the embedded filesystem instead of reading from disk if err := serveStaticFile(c, "frontend/build"); err != nil { // If not found in build, try static folder if err := serveStaticFile(c, "frontend/static"); err != nil { // Return 404 for static files that should exist c.Status(http.StatusNotFound) return } } return } // For SPA navigation - serve index.html for all non-API routes // Use the embedded filesystem if err := serveStaticFile(c, "frontend/build"); err != nil { c.String(http.StatusInternalServerError, "Error reading index.html") return } } // isStaticFileRequest determines if a path is for a static file (CSS, JS, image, etc.) func isStaticFileRequest(path string) bool { staticExtensions := []string{ ".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".json", ".woff", ".woff2", ".ttf", ".eot", } for _, ext := range staticExtensions { if strings.HasSuffix(path, ext) { return true } } return false }