package main import ( "fmt" "log" "net/http" "os" "path/filepath" "strings" "github.com/gorilla/mux" "github.com/jinzhu/gorm" "github.com/rs/cors" ) // Global variables var ( db *gorm.DB config *Config JWTSecret *[]byte ImageStorage *string DatabasePath *string ) func main() { // Load configuration first var err error config, err = loadAndValidateConfig() if err != nil { log.Fatalf("Failed to load config: %v", err) } // Initialize logger with config values logger, err := Initialize(config.LogFile, "both") // "both" means log to both file and stdout if err != nil { log.Fatalf("Failed to initialize logger: %v", err) } defer logger.Close() // Set up logging configuration if err := setupLogging(config.LogLevel); err != nil { logger.Error("Failed to set up logging: %v", err) os.Exit(1) } // Log configuration details logConfigDetails(config) // Connect to database db, err = connectToDatabase(config) if err != nil { logger.Error("Failed to connect to database: %v", err) os.Exit(1) } defer db.Close() // Create and configure the router router := createRouter() // Create CORS handler corsHandler := createCORSHandler(config.AllowedOrigins) // Start the server startServer(config.ListeningPort, corsHandler(router)) } func createRouter() *mux.Router { baseRouter := mux.NewRouter() baseRouter.Use(loggingMiddleware) // API routes should be registered first with a strict prefix match apiRouter := baseRouter.PathPrefix("/api/v1").Subrouter() apiRouter.StrictSlash(true) // This ensures /api/v1/ and /api/v1 are treated the same setupAPIRoutes(apiRouter) // Static file serving should be last and only match if no API routes matched staticRouter := baseRouter.NewRoute().Subrouter() setupStaticRoutes(staticRouter, config.StaticFilesDir) // Add a catch-all NotFoundHandler to the base router baseRouter.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // If the path starts with /api, return a 404 API error if strings.HasPrefix(r.URL.Path, "/api/") { http.Error(w, "API endpoint not found", http.StatusNotFound) return } // Otherwise serve the index.html for client-side routing http.ServeFile(w, r, filepath.Join(config.StaticFilesDir, "index.html")) }) return baseRouter } func setupAPIRoutes(router *mux.Router) { // Public routes router.Handle("/login", http.HandlerFunc(LoginHandler)).Methods("POST", "OPTIONS") // Protected routes protected := router.NewRoute().Subrouter() protected.Use(AuthMiddleware) // Box routes protected.Handle("/boxes", http.HandlerFunc(GetBoxesHandler)).Methods("GET", "OPTIONS") protected.Handle("/boxes", http.HandlerFunc(CreateBoxHandler)).Methods("POST", "OPTIONS") protected.Handle("/boxes/{id}", http.HandlerFunc(GetBoxHandler)).Methods("GET", "OPTIONS") protected.Handle("/boxes/{id}", http.HandlerFunc(DeleteBoxHandler)).Methods("DELETE", "OPTIONS") protected.Handle("/boxes/{id}/items", http.HandlerFunc(GetItemsInBoxHandler)).Methods("GET", "OPTIONS") // Item routes protected.Handle("/items", http.HandlerFunc(GetItemsHandler)).Methods("GET", "OPTIONS") protected.Handle("/items", http.HandlerFunc(CreateItemHandler)).Methods("POST", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(GetItemHandler)).Methods("GET", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(UpdateItemHandler)).Methods("PUT", "OPTIONS") protected.Handle("/items/{id}", http.HandlerFunc(DeleteItemHandler)).Methods("DELETE", "OPTIONS") protected.Handle("/items/{id}/image", http.HandlerFunc(GetItemImageHandler)).Methods("GET", "OPTIONS") protected.Handle("/items/{id}/upload", http.HandlerFunc(UploadItemImageHandler)).Methods("POST", "OPTIONS") protected.Handle("/search/items", http.HandlerFunc(SearchItemsHandler)).Methods("GET", "OPTIONS") // Tag routes protected.Handle("/tags", http.HandlerFunc(GetTagsHandler)).Methods("GET", "OPTIONS") protected.Handle("/tags", http.HandlerFunc(CreateTagHandler)).Methods("POST", "OPTIONS") protected.Handle("/tags/{id}", http.HandlerFunc(UpdateTagHandler)).Methods("PUT", "OPTIONS") protected.Handle("/tags/{id}", http.HandlerFunc(DeleteTagHandler)).Methods("DELETE", "OPTIONS") protected.Handle("/tags/{id}/items", http.HandlerFunc(GetItemsByTagHandler)).Methods("GET", "OPTIONS") // Item-Tag relationship routes protected.Handle("/items/{id}/tags", http.HandlerFunc(AddItemTagsHandler)).Methods("POST", "OPTIONS") protected.Handle("/items/{id}/tags/{tagId}", http.HandlerFunc(RemoveItemTagHandler)).Methods("DELETE", "OPTIONS") // Admin routes adminRouter := protected.PathPrefix("/admin").Subrouter() setupManagementRoutes(adminRouter) } func setupManagementRoutes(router *mux.Router) { router.Handle("/user", http.HandlerFunc(GetUsersHandler)).Methods("GET", "OPTIONS") router.Handle("/user", http.HandlerFunc(CreateUserHandler)).Methods("POST", "OPTIONS") router.Handle("/user/{id}", http.HandlerFunc(GetUserHandler)).Methods("GET", "OPTIONS") router.Handle("/user/{id}", http.HandlerFunc(DeleteUserHandler)).Methods("DELETE", "OPTIONS") router.Handle("/db", http.HandlerFunc(BackupDatabaseHandler)).Methods("GET", "OPTIONS") router.Handle("/db", http.HandlerFunc(RestoreDatabaseHandler)).Methods("POST", "OPTIONS") } func setupStaticRoutes(router *mux.Router, staticPath string) { // Ensure static directory exists if err := validateStaticDirectory(staticPath); err != nil { log.Printf("Warning: Static directory validation failed: %v", err) } fileServer := http.FileServer(http.Dir(staticPath)) // Only serve static files for paths that don't start with /api router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logger := GetLogger() // Don't serve static files for API paths if strings.HasPrefix(r.URL.Path, "/api/") { http.NotFound(w, r) return } // Log static file request if logger != nil { logger.Debug("Static file request: %s", r.URL.Path) } // Check if the file exists path := filepath.Join(staticPath, r.URL.Path) _, err := os.Stat(path) // If file doesn't exist, serve index.html for SPA routing if os.IsNotExist(err) { if logger != nil { logger.Debug("File not found, serving index.html: %s", path) } http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) return } // Strip the leading "/" before serving http.StripPrefix("/", fileServer).ServeHTTP(w, r) })) } func createCustomStaticHandler(staticPath string) http.HandlerFunc { fileServer := http.FileServer(http.Dir(staticPath)) return func(w http.ResponseWriter, r *http.Request) { logger := GetLogger() // Log static file request if logger != nil { logger.Debug("Static file request: %s", r.URL.Path) } // Check if the file exists path := filepath.Join(staticPath, r.URL.Path) _, err := os.Stat(path) // If file doesn't exist, serve index.html for SPA routing if os.IsNotExist(err) { if logger != nil { logger.Debug("File not found, serving index.html: %s", path) } http.ServeFile(w, r, filepath.Join(staticPath, "index.html")) return } fileServer.ServeHTTP(w, r) } } func loadAndValidateConfig() (*Config, error) { configFile := os.Getenv("BOXES_API_CONFIG") if configFile == "" { configFile = "config.yaml" // Default config path } var err error config, err := LoadConfig(configFile) if err != nil { return nil, fmt.Errorf("failed to load config: %v", err) } // Set global variables jwtSecretBytes := []byte(config.JWTSecret) JWTSecret = &jwtSecretBytes ImageStorage = &config.ImageStorageDir DatabasePath = &config.DatabasePath return config, nil } func setupLogging(logLevel string) error { logger := GetLogger() if logger == nil { return fmt.Errorf("logger not initialized") } if logLevel != "" { if err := logger.SetLogLevel(logLevel); err != nil { return fmt.Errorf("failed to set log level: %v", err) } } return nil } func logConfigDetails(config *Config) { logger := GetLogger() if logger == nil { return } logger.Info("Configuration loaded:") logger.Info("Database Path: %s", config.DatabasePath) logger.Info("Image Storage Dir: %s", config.ImageStorageDir) logger.Info("Log File: %s", config.LogFile) logger.Info("Log Level: %s", config.LogLevel) logger.Info("Listening Port: %d", config.ListeningPort) logger.Info("Allowed Origins: %s", config.AllowedOrigins) logger.Info("Static Files Dir: %s", config.StaticFilesDir) } func connectToDatabase(config *Config) (*gorm.DB, error) { if config == nil { return nil, fmt.Errorf("config is nil") } return ConnectDB(config.DatabasePath) } func createCORSHandler(allowedOrigins string) func(http.Handler) http.Handler { corsOpts := cors.Options{ AllowedOrigins: []string{allowedOrigins}, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Length"}, AllowCredentials: true, } // If allowedOrigins is "*", allow all origins if allowedOrigins == "*" { corsOpts.AllowedOrigins = []string{"*"} } return cors.New(corsOpts).Handler } func startServer(port int, handler http.Handler) { logger := GetLogger() addr := fmt.Sprintf(":%d", port) if logger != nil { logger.Info("Server starting on port %d", port) } if err := http.ListenAndServe(addr, handler); err != nil { if logger != nil { logger.Error("Server failed to start: %v", err) } os.Exit(1) } } func validateStaticDirectory(path string) error { if info, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return fmt.Errorf("static directory does not exist: %s", path) } return fmt.Errorf("error accessing static directory: %v", err) } else if !info.IsDir() { return fmt.Errorf("static path is not a directory: %s", path) } return nil } type responseWriter struct { http.ResponseWriter statusCode int } func (rw *responseWriter) WriteHeader(code int) { rw.statusCode = code rw.ResponseWriter.WriteHeader(code) } func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { logger := GetLogger() username := "anonymous" if user, ok := r.Context().Value(userKey).(string); ok { username = user } rw := &responseWriter{w, http.StatusOK} next.ServeHTTP(rw, r) if logger != nil { logger.HTTPRequest(r.Method, r.URL.Path, username, rw.statusCode) } }) }