diff --git a/cmd/pfs/main.go b/cmd/pfs/main.go index 95a2899..9268a29 100644 --- a/cmd/pfs/main.go +++ b/cmd/pfs/main.go @@ -12,6 +12,8 @@ import ( "os/signal" "path/filepath" "runtime" + "strconv" + "strings" "syscall" "time" @@ -29,6 +31,8 @@ import ( "git.mchus.pro/mchus/priceforge/internal/services/pricelist" "git.mchus.pro/mchus/priceforge/internal/services/pricing" "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" @@ -78,6 +82,16 @@ func main() { 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) @@ -144,7 +158,7 @@ func main() { } gin.SetMode(cfg.Server.Mode) - router, err := setupRouter(cfg, connMgr, mariaDB, dbUser) + router, err := setupRouter(cfg, resolvedConfigPath, connMgr, mariaDB, dbUser) if err != nil { slog.Error("failed to setup router", "error", err) os.Exit(1) @@ -231,6 +245,29 @@ func setConfigDefaults(cfg *config.Config) { } } +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 { @@ -278,7 +315,7 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) { return db, nil } -func setupRouter(cfg *config.Config, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUser string) (*gin.Engine, error) { +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 @@ -320,6 +357,7 @@ func setupRouter(cfg *config.Config, connMgr *db.ConnectionManager, mariaDB *gor } 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()) @@ -388,6 +426,107 @@ func setupRouter(cfg *config.Config, connMgr *db.ConnectionManager, mariaDB *gor }) }) + router.GET("/api/connection-settings", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "host": cfg.Database.Host, + "port": cfg.Database.Port, + "database": cfg.Database.Name, + "user": cfg.Database.User, + }) + }) + + router.POST("/api/connection-settings/test", func(c *gin.Context) { + host := strings.TrimSpace(c.PostForm("host")) + database := strings.TrimSpace(c.PostForm("database")) + user := strings.TrimSpace(c.PostForm("user")) + password := c.PostForm("password") + port, err := strconv.Atoi(strings.TrimSpace(c.DefaultPostForm("port", strconv.Itoa(cfg.Database.Port)))) + if err != nil || port <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid port"}) + return + } + + if host == "" || database == "" || user == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "all fields except password are required"}) + return + } + if password == "" { + password = cfg.Database.Password + } + + dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) + testDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("connection failed: %v", err)}) + return + } + sqlDB, err := testDB.DB() + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("failed to get database handle: %v", err)}) + return + } + defer sqlDB.Close() + if err := sqlDB.Ping(); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": fmt.Sprintf("ping failed: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "message": "connection successful"}) + }) + + router.POST("/api/connection-settings", func(c *gin.Context) { + host := strings.TrimSpace(c.PostForm("host")) + database := strings.TrimSpace(c.PostForm("database")) + user := strings.TrimSpace(c.PostForm("user")) + password := c.PostForm("password") + port, err := strconv.Atoi(strings.TrimSpace(c.DefaultPostForm("port", strconv.Itoa(cfg.Database.Port)))) + if err != nil || port <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "invalid port"}) + return + } + if host == "" || database == "" || user == "" { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "all fields except password are required"}) + return + } + if password == "" { + password = cfg.Database.Password + } + + dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) + testDB, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("connection failed: %v", err)}) + return + } + sqlDB, err := testDB.DB() + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("failed to get database handle: %v", err)}) + return + } + if err := sqlDB.Ping(); err != nil { + _ = sqlDB.Close() + c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": fmt.Sprintf("ping failed: %v", err)}) + return + } + _ = sqlDB.Close() + + cfg.Database.Host = host + cfg.Database.Port = port + cfg.Database.Name = database + cfg.Database.User = user + cfg.Database.Password = password + if err := saveConfig(configPath, cfg); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": fmt.Sprintf("failed to save config: %v", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "settings saved, restart required", + "restart_required": true, + }) + }) + router.GET("/api/current-user", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "username": dbUser, @@ -520,3 +659,30 @@ func requestLogger() gin.HandlerFunc { ) } } + +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 +} diff --git a/web/templates/base.html b/web/templates/base.html index ceff4c7..8f4ad74 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -21,7 +21,7 @@