package main import ( "context" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "strings" "syscall" "time" qfassets "git.mchus.pro/mchus/priceforge" "git.mchus.pro/mchus/priceforge/internal/appmeta" "git.mchus.pro/mchus/priceforge/internal/appstate" "git.mchus.pro/mchus/priceforge/internal/config" "git.mchus.pro/mchus/priceforge/internal/db" "git.mchus.pro/mchus/priceforge/internal/handlers" "git.mchus.pro/mchus/priceforge/internal/middleware" "git.mchus.pro/mchus/priceforge/internal/models" "git.mchus.pro/mchus/priceforge/internal/repository" "git.mchus.pro/mchus/priceforge/internal/scheduler" "git.mchus.pro/mchus/priceforge/internal/services" "git.mchus.pro/mchus/priceforge/internal/services/alerts" "git.mchus.pro/mchus/priceforge/internal/services/pricelist" "git.mchus.pro/mchus/priceforge/internal/services/pricing" "git.mchus.pro/mchus/priceforge/internal/tasks" "github.com/gin-gonic/gin" mysqlDriver "github.com/go-sql-driver/mysql" "gopkg.in/yaml.v3" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // Version is set via ldflags during build var Version = "dev" const backgroundSyncInterval = 5 * time.Minute func main() { configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)") migrate := flag.Bool("migrate", false, "run database migrations") version := flag.Bool("version", false, "show version information") flag.Parse() // Show version if requested if *version { fmt.Printf("pfs version %s\n", Version) os.Exit(0) } exePath, _ := os.Executable() slog.Info("starting pfs", "version", Version, "executable", exePath) appmeta.SetVersion(Version) resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath) if err != nil { slog.Error("failed to resolve config path", "error", err) os.Exit(1) } // Migrate legacy project-local config path to the user state directory when using defaults. if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" { migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"}) if migrateErr != nil { slog.Warn("failed to migrate legacy config file", "error", migrateErr) } else if migratedFrom != "" { slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath) } } // Load config for server settings cfg, err := config.Load(resolvedConfigPath) if err != nil { slog.Error("failed to load config", "path", resolvedConfigPath, "error", err) os.Exit(1) } setConfigDefaults(cfg) cfg.Server.Host = normalizeLocalHost(cfg.Server.Host) if !isLoopbackHost(cfg.Server.Host) { slog.Error( "server host must be loopback-only for local mode", "host", cfg.Server.Host, "allowed", "127.0.0.1/localhost/::1", "config_path", resolvedConfigPath, ) os.Exit(1) } slog.Info("resolved runtime files", "config_path", resolvedConfigPath) setupLogger(cfg.Logging) dsn := cfg.Database.DSN() dsnHost := net.JoinHostPort(cfg.Database.Host, fmt.Sprintf("%d", cfg.Database.Port)) connMgr := db.NewConnectionManager(dsn, dsnHost) dbUser := cfg.Database.User // Fail-fast mode: MariaDB must be available on startup. mariaDB, err := connMgr.GetDB() if err != nil { slog.Error("failed to connect to MariaDB on startup", "error", err) os.Exit(1) } slog.Info("successfully connected to MariaDB on startup") slog.Info("starting PriceForge server", "version", Version, "host", cfg.Server.Host, "port", cfg.Server.Port, "db_user", dbUser, "online", true, ) if *migrate { slog.Info("running database migrations...") if err := models.Migrate(mariaDB); err != nil { slog.Error("migration failed", "error", err) os.Exit(1) } if err := models.SeedCategories(mariaDB); err != nil { slog.Error("seeding categories failed", "error", err) os.Exit(1) } slog.Info("gorm migrations completed") // Also apply SQL migrations in migrate-only mode. sqlMigrationsPath := filepath.Join("migrations") needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath) if err != nil { if models.IsMigrationPermissionError(err) { slog.Info("SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else { slog.Error("SQL migrations check failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else if needsMigrations { if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { if models.IsMigrationPermissionError(err) { slog.Info("SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else { slog.Error("SQL migrations failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else { slog.Info("SQL migrations applied", "path", sqlMigrationsPath) } } else { slog.Info("SQL migrations not needed", "path", sqlMigrationsPath) } slog.Info("migrate-only mode completed; exiting without starting web server") return } // Always apply SQL migrations on startup when database is available. // This keeps schema in sync for long-running installations without manual steps. // If current DB user does not have enough privileges, continue startup in normal mode. sqlMigrationsPath := filepath.Join("migrations") needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath) if err != nil { if models.IsMigrationPermissionError(err) { slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else if needsMigrations { slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else if needsMigrations { if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil { if models.IsMigrationPermissionError(err) { slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err) } else { slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err) os.Exit(1) } } else { slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath) } } else { slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath) } gin.SetMode(cfg.Server.Mode) router, err := setupRouter(cfg, resolvedConfigPath, connMgr, mariaDB, dbUser) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) } appCtx, appCancel := context.WithCancel(context.Background()) defer appCancel() startEmbeddedScheduler(appCtx, mariaDB, cfg) srv := &http.Server{ Addr: cfg.Address(), Handler: router, ReadTimeout: cfg.Server.ReadTimeout, WriteTimeout: cfg.Server.WriteTimeout, } go func() { slog.Info("server listening", "address", cfg.Address()) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server error", "error", err) os.Exit(1) } }() // Automatically open browser after server starts (with a small delay) go func() { time.Sleep(1 * time.Second) // Always use localhost for browser, even if server binds to 0.0.0.0 browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port) slog.Info("Opening browser to", "url", browserURL) err := openBrowser(browserURL) if err != nil { slog.Warn("Failed to open browser", "error", err) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit slog.Info("shutting down server...") appCancel() // Shutdown HTTP server ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(ctx); err != nil { slog.Error("server forced to shutdown", "error", err) } slog.Info("server stopped") } func startEmbeddedScheduler(ctx context.Context, mariaDB *gorm.DB, cfg *config.Config) { if mariaDB == nil || cfg == nil || !cfg.Scheduler.Enabled { return } statsRepo := repository.NewStatsRepository(mariaDB) alertRepo := repository.NewAlertRepository(mariaDB) componentRepo := repository.NewComponentRepository(mariaDB) priceRepo := repository.NewPriceRepository(mariaDB) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) embeddedScheduler := scheduler.New(mariaDB, alertService, pricingService, statsRepo, cfg.Scheduler) go embeddedScheduler.Start(ctx) } func setConfigDefaults(cfg *config.Config) { if cfg.Server.Host == "" { cfg.Server.Host = "127.0.0.1" } if cfg.Server.Port == 0 { cfg.Server.Port = 8084 } if cfg.Server.Mode == "" { cfg.Server.Mode = "release" } if cfg.Server.ReadTimeout == 0 { cfg.Server.ReadTimeout = 30 * time.Second } if cfg.Server.WriteTimeout == 0 { cfg.Server.WriteTimeout = 30 * time.Second } if cfg.Pricing.DefaultMethod == "" { cfg.Pricing.DefaultMethod = "weighted_median" } if cfg.Pricing.DefaultPeriodDays == 0 { cfg.Pricing.DefaultPeriodDays = 90 } if cfg.Pricing.FreshnessGreenDays == 0 { cfg.Pricing.FreshnessGreenDays = 30 } if cfg.Pricing.FreshnessYellowDays == 0 { cfg.Pricing.FreshnessYellowDays = 60 } if cfg.Pricing.FreshnessRedDays == 0 { cfg.Pricing.FreshnessRedDays = 90 } if cfg.Pricing.MinQuotesForMedian == 0 { cfg.Pricing.MinQuotesForMedian = 3 } if cfg.Scheduler.PollInterval == 0 { cfg.Scheduler.PollInterval = time.Minute } if cfg.Alerts.CheckInterval == 0 { cfg.Alerts.CheckInterval = time.Hour } if cfg.Scheduler.AlertsInterval == 0 { cfg.Scheduler.AlertsInterval = cfg.Alerts.CheckInterval } if cfg.Scheduler.UpdatePricesInterval == 0 { cfg.Scheduler.UpdatePricesInterval = 24 * time.Hour } if cfg.Scheduler.UpdatePopularityInterval == 0 { cfg.Scheduler.UpdatePopularityInterval = 24 * time.Hour } if cfg.Scheduler.ResetWeeklyCountersInterval == 0 { cfg.Scheduler.ResetWeeklyCountersInterval = 7 * 24 * time.Hour } if cfg.Scheduler.ResetMonthlyCountersInterval == 0 { cfg.Scheduler.ResetMonthlyCountersInterval = 30 * 24 * time.Hour } } func isLoopbackHost(host string) bool { h := strings.TrimSpace(strings.ToLower(host)) if h == "" { return false } if h == "localhost" { return true } ip := net.ParseIP(h) return ip != nil && ip.IsLoopback() } func normalizeLocalHost(host string) string { h := strings.TrimSpace(strings.ToLower(host)) switch h { case "0.0.0.0", "::": slog.Warn("non-loopback bind address overridden for local mode", "from", host, "to", "127.0.0.1") return "127.0.0.1" default: return host } } func setupLogger(cfg config.LoggingConfig) { var level slog.Level switch cfg.Level { case "debug": level = slog.LevelDebug case "warn": level = slog.LevelWarn case "error": level = slog.LevelError default: level = slog.LevelInfo } opts := &slog.HandlerOptions{Level: level} var handler slog.Handler if cfg.Format == "json" { handler = slog.NewJSONHandler(os.Stdout, opts) } else { handler = slog.NewTextHandler(os.Stdout, opts) } slog.SetDefault(slog.New(handler)) } func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { gormLogger := logger.Default.LogMode(logger.Silent) db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: gormLogger, }) if err != nil { return nil, err } sqlDB, err := db.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(25) sqlDB.SetMaxIdleConns(5) sqlDB.SetConnMaxLifetime(5 * time.Minute) return db, nil } func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUser string) (*gin.Engine, error) { var componentRepo *repository.ComponentRepository var categoryRepo *repository.CategoryRepository var priceRepo *repository.PriceRepository var alertRepo *repository.AlertRepository var statsRepo *repository.StatsRepository var pricelistRepo *repository.PricelistRepository if mariaDB != nil { componentRepo = repository.NewComponentRepository(mariaDB) categoryRepo = repository.NewCategoryRepository(mariaDB) priceRepo = repository.NewPriceRepository(mariaDB) alertRepo = repository.NewAlertRepository(mariaDB) statsRepo = repository.NewStatsRepository(mariaDB) pricelistRepo = repository.NewPricelistRepository(mariaDB) } pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing) componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo) alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing) pricelistService := pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService) stockImportService := services.NewStockImportService(mariaDB, pricelistService) vendorMappingService := services.NewVendorMappingService(mariaDB) partnumberBookService := services.NewPartnumberBookService(mariaDB) // Create task manager taskManager := tasks.NewManager() templatesPath := filepath.Join("web", "templates") componentHandler := handlers.NewComponentHandler(componentService) pricingHandler := handlers.NewPricingHandler( mariaDB, pricingService, alertService, componentRepo, componentService, priceRepo, statsRepo, stockImportService, vendorMappingService, partnumberBookService, cfg.Scheduler, dbUser, taskManager, ) pricelistHandler := handlers.NewPricelistHandler(pricelistService, dbUser, taskManager) taskHandler := tasks.NewHandler(taskManager) setupHandler, err := handlers.NewSetupHandler(connMgr, templatesPath, cfg, func(nextCfg *config.Config) error { return saveConfig(configPath, nextCfg) }) if err != nil { return nil, err } webHandler, err := handlers.NewWebHandler(templatesPath, componentService) if err != nil { return nil, err } router := gin.New() router.MaxMultipartMemory = 26 << 20 // 26MB; stock import handler enforces 25MB payload limit router.Use(gin.Recovery()) router.Use(requestLogger()) router.Use(middleware.CORS()) router.Use(middleware.OfflineDetector(connMgr)) staticPath := filepath.Join("web", "static") if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() { router.Static("/static", staticPath) } else if staticFS, err := qfassets.StaticFS(); err == nil { router.StaticFS("/static", http.FS(staticFS)) } router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "status": "ok", "time": time.Now().UTC().Format(time.RFC3339), }) }) router.POST("/api/restart", func(c *gin.Context) { slog.Info("Restart requested via API") go func() { time.Sleep(100 * time.Millisecond) restartProcess() }() c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) }) router.GET("/api/db-status", func(c *gin.Context) { var lotCount, lotLogCount, metadataCount int64 var dbOK bool var dbError string includeCounts := c.Query("include_counts") == "true" status := connMgr.GetStatus() dbOK = status.IsConnected if !status.IsConnected { dbError = "Database not connected (offline mode)" if status.LastError != "" { dbError = status.LastError } } if includeCounts && status.IsConnected { if db, err := connMgr.GetDB(); err == nil && db != nil { _ = db.Table("lot").Count(&lotCount) _ = db.Table("lot_log").Count(&lotLogCount) _ = db.Table("qt_lot_metadata").Count(&metadataCount) } else if err != nil { dbOK = false dbError = err.Error() } else { dbOK = false dbError = "Database not connected (offline mode)" } } c.JSON(http.StatusOK, gin.H{ "connected": dbOK, "error": dbError, "lot_count": lotCount, "lot_log_count": lotLogCount, "metadata_count": metadataCount, "db_user": dbUser, "db_host": status.DSNHost, }) }) router.GET("/api/current-user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "username": dbUser, "role": "db_user", }) }) router.GET("/", webHandler.Index) router.GET("/setup", setupHandler.ShowSetup) router.POST("/setup", setupHandler.SaveConnection) router.POST("/setup/test", setupHandler.TestConnection) router.GET("/lot", webHandler.Lot) router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists/:id", webHandler.PricelistDetail) router.GET("/admin/pricing", webHandler.AdminPricing) router.GET("/vendor-mappings", webHandler.VendorMappings) partials := router.Group("/partials") { partials.GET("/components", webHandler.ComponentsPartial) } api := router.Group("/api") { api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) }) api.GET("/tasks", taskHandler.List) api.GET("/tasks/:id", taskHandler.Get) components := api.Group("/components") { components.GET("", componentHandler.List) components.GET("/:lot_name", componentHandler.Get) } api.GET("/categories", componentHandler.GetCategories) pricelists := api.Group("/pricelists") { pricelists.GET("", pricelistHandler.List) pricelists.GET("/can-write", pricelistHandler.CanWrite) pricelists.GET("/latest", pricelistHandler.GetLatest) pricelists.GET("/:id", pricelistHandler.Get) pricelists.GET("/:id/price-changes", pricelistHandler.GetPriceChanges) pricelists.GET("/:id/items", pricelistHandler.GetItems) pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) pricelists.GET("/:id/export-csv", pricelistHandler.ExportCSV) pricelists.POST("", pricelistHandler.Create) pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress) pricelists.PATCH("/:id/active", pricelistHandler.SetActive) pricelists.DELETE("/:id", pricelistHandler.Delete) } pricingAdmin := api.Group("/admin/pricing") { pricingAdmin.GET("/stats", pricingHandler.GetStats) pricingAdmin.GET("/components", pricingHandler.ListComponents) pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing) pricingAdmin.POST("/update", pricingHandler.UpdatePrice) pricingAdmin.POST("/preview", pricingHandler.PreviewPrice) pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll) pricingAdmin.GET("/lots", pricingHandler.ListLots) pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable) pricingAdmin.GET("/lots/:lot_name/stats", pricingHandler.GetLotStats) pricingAdmin.POST("/lots", pricingHandler.CreateLot) pricingAdmin.POST("/lots/sync-metadata", pricingHandler.SyncLotsMetadata) pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog) pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings) pricingAdmin.GET("/stock/unmapped-partnumbers", pricingHandler.GetUnmappedPartnumbers) pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping) pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping) pricingAdmin.GET("/vendor-mappings", pricingHandler.ListVendorMappings) pricingAdmin.GET("/vendor-mappings/detail", pricingHandler.GetVendorMappingDetail) pricingAdmin.POST("/vendor-mappings", pricingHandler.UpsertVendorMapping) pricingAdmin.POST("/vendor-mappings/import-csv", pricingHandler.ImportVendorMappingsCSV) pricingAdmin.GET("/vendor-mappings/export-unmapped-csv", pricingHandler.ExportUnmappedVendorMappingsCSV) pricingAdmin.DELETE("/vendor-mappings", pricingHandler.DeleteVendorMapping) pricingAdmin.POST("/vendor-mappings/ignore", pricingHandler.IgnoreVendorMapping) pricingAdmin.POST("/vendor-mappings/unignore", pricingHandler.UnignoreVendorMapping) pricingAdmin.GET("/alerts", pricingHandler.ListAlerts) pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert) pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert) pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert) pricingAdmin.GET("/scheduler-runs", pricingHandler.ListSchedulerRuns) pricingAdmin.GET("/partnumber-books", pricingHandler.ListPartnumberBooks) pricingAdmin.POST("/partnumber-books", pricingHandler.CreatePartnumberBook) } } return router, nil } // restartProcess restarts the current process with the same arguments func restartProcess() { executable, err := os.Executable() if err != nil { slog.Error("failed to get executable path", "error", err) os.Exit(1) } args := os.Args env := os.Environ() slog.Info("executing restart", "executable", executable, "args", args) err = syscall.Exec(executable, args, env) if err != nil { slog.Error("failed to restart process", "error", err) os.Exit(1) } } func openBrowser(url string) error { var cmd string var args []string switch runtime.GOOS { case "windows": cmd = "cmd" args = []string{"/c", "start", url} case "darwin": cmd = "open" args = []string{url} default: // "linux", "freebsd", "openbsd", "netbsd" cmd = "xdg-open" args = []string{url} } return exec.Command(cmd, args...).Start() } func requestLogger() gin.HandlerFunc { // Skip logging for frequent polling endpoints skipPaths := map[string]bool{ "/api/tasks": true, "/api/db-status": true, } return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path query := c.Request.URL.RawQuery c.Next() // Skip logging for frequent polling endpoints if skipPaths[path] { return } latency := time.Since(start) status := c.Writer.Status() slog.Info("request", "method", c.Request.Method, "path", path, "query", query, "status", status, "latency", latency, "ip", c.ClientIP(), ) } } func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string { cfg := mysqlDriver.NewConfig() cfg.User = user cfg.Passwd = password cfg.Net = "tcp" cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port)) cfg.DBName = database cfg.ParseTime = true cfg.Loc = time.Local cfg.Timeout = timeout cfg.Params = map[string]string{ "charset": "utf8mb4", } return cfg.FormatDSN() } func saveConfig(path string, cfg *config.Config) error { data, err := yaml.Marshal(cfg) if err != nil { return fmt.Errorf("marshal config: %w", err) } if err := os.WriteFile(path, data, 0600); err != nil { return fmt.Errorf("write config: %w", err) } return nil }