diff --git a/README.md b/README.md index 47cdc2b..7a8f1bb 100644 --- a/README.md +++ b/README.md @@ -113,30 +113,52 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes Если нужен пользователь, который может работать с конфигурациями, но не может создавать/удалять прайслисты: ```sql --- 1) Создать (или оставить существующего) пользователя +-- 1) Создать пользователя (если его ещё нет) CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER'; --- 2) Сбросить лишние права (без пересоздания пользователя) +-- 2) Если пользователь уже существовал, принудительно обновить пароль +ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER'; + +-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host, +-- чтобы не возникало конфликтов вида user@localhost vs user@'%' +DROP USER IF EXISTS 'quote_user'@'localhost'; +DROP USER IF EXISTS 'quote_user'@'127.0.0.1'; +DROP USER IF EXISTS 'quote_user'@'::1'; + +-- 4) Сбросить лишние права REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'quote_user'@'%'; --- 3) Чтение данных для конфигуратора и синка +-- 5) Чтение данных для конфигуратора и синка GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; --- 4) Работа с конфигурациями +-- 6) Работа с конфигурациями GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; FLUSH PRIVILEGES; SHOW GRANTS FOR 'quote_user'@'%'; +SHOW CREATE USER 'quote_user'@'%'; +``` + +Полный набор прав для пользователя квотаций: + +```sql +GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER'; +GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%'; +GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%'; +GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%'; ``` Важно: - не выдавайте `INSERT/UPDATE/DELETE` на `qt_pricelists` и `qt_pricelist_items`, если пользователь не должен управлять прайслистами; -- если используется host-специфичный аккаунт (`'quote_user'@'192.168.x.x'`), назначьте права и для него; +- если видите ошибку `Access denied for user ...@''`, проверьте, что не осталось других записей `quote_user@host` кроме `quote_user@'%'`; - после смены DB-настроек через `/setup` приложение перезапускается автоматически и подхватывает нового пользователя. ### 4. Импорт метаданных компонентов diff --git a/internal/config/config.go b/internal/config/config.go index acc0046..d0cf829 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,9 +2,12 @@ package config import ( "fmt" + "net" "os" + "strconv" "time" + mysqlDriver "github.com/go-sql-driver/mysql" "gopkg.in/yaml.v3" ) @@ -39,8 +42,18 @@ type DatabaseConfig struct { } func (d *DatabaseConfig) DSN() string { - return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", - d.User, d.Password, d.Host, d.Port, d.Name) + cfg := mysqlDriver.NewConfig() + cfg.User = d.User + cfg.Passwd = d.Password + cfg.Net = "tcp" + cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port)) + cfg.DBName = d.Name + cfg.ParseTime = true + cfg.Loc = time.Local + cfg.Params = map[string]string{ + "charset": "utf8mb4", + } + return cfg.FormatDSN() } type AuthConfig struct { diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 5598376..60c927b 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -4,6 +4,7 @@ import ( "fmt" "html/template" "log/slog" + "net" "net/http" "os" "path/filepath" @@ -13,8 +14,9 @@ import ( qfassets "git.mchus.pro/mchus/quoteforge" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" + mysqlDriver "github.com/go-sql-driver/mysql" "github.com/gin-gonic/gin" - "gorm.io/driver/mysql" + gormmysql "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) @@ -93,10 +95,9 @@ func (h *SetupHandler) TestConnection(c *gin.Context) { } } - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", - user, password, host, port, database) + dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { @@ -169,10 +170,9 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { } // Test connection first - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=5s", - user, password, host, port, database) + dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) - db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { @@ -254,3 +254,19 @@ func testWritePermission(db *gorm.DB) bool { return true } + +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() +} diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 312d1fc..09b4d70 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -4,12 +4,15 @@ import ( "errors" "fmt" "log/slog" + "net" "os" "path/filepath" + "strconv" "strings" "time" "git.mchus.pro/mchus/quoteforge/internal/appmeta" + mysqlDriver "github.com/go-sql-driver/mysql" "github.com/glebarez/sqlite" uuidpkg "github.com/google/uuid" "gorm.io/gorm" @@ -141,19 +144,23 @@ func (l *LocalDB) GetDSN() (string, error) { return "", err } - // Add aggressive timeouts for offline-first architecture - // timeout: connection establishment timeout (3s) - // readTimeout: I/O read timeout (3s) - // writeTimeout: I/O write timeout (3s) - dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s", - settings.User, - settings.PasswordEncrypted, // Contains decrypted password after GetSettings - settings.Host, - settings.Port, - settings.Database, - ) + cfg := mysqlDriver.NewConfig() + cfg.User = settings.User + cfg.Passwd = settings.PasswordEncrypted // Contains decrypted password after GetSettings + cfg.Net = "tcp" + cfg.Addr = net.JoinHostPort(settings.Host, strconv.Itoa(settings.Port)) + cfg.DBName = settings.Database + cfg.ParseTime = true + cfg.Loc = time.Local + // Add aggressive timeouts for offline-first architecture. + cfg.Timeout = 3 * time.Second + cfg.ReadTimeout = 3 * time.Second + cfg.WriteTimeout = 3 * time.Second + cfg.Params = map[string]string{ + "charset": "utf8mb4", + } - return dsn, nil + return cfg.FormatDSN(), nil } // DB returns the underlying gorm.DB for advanced operations