package mysqlimport import ( "context" "fmt" "log" "os" "regexp" "strconv" "strings" "unicode/utf8" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( reInsert = regexp.MustCompile(`(?is)INSERT INTO\s+\x60?(\w+)\x60?\s+VALUES\s*(.+?);`) reCreate = regexp.MustCompile(`(?is)CREATE TABLE\s+\x60?(\w+)\x60?`) ) // ImportFile converts and loads a MySQL dump into PostgreSQL once. func ImportFile(ctx context.Context, pool *pgxpool.Pool, dumpPath string) error { var done string err := pool.QueryRow(ctx, `SELECT value FROM app_meta WHERE key = 'mysql_import_done'`).Scan(&done) if err == nil && done == "1" { log.Println("mysql import already done, skipping") return nil } raw, err := os.ReadFile(dumpPath) if err != nil { return fmt.Errorf("read dump: %w", err) } if !utf8.Valid(raw) { // try windows-1251/latin1 fallback: replace invalid raw = []byte(strings.ToValidUTF8(string(raw), "?")) } text := string(raw) tablesOrder := []string{ "users", "site_status", "servers", "servers_simple", "countries", "wg_configs", "wg_config_settings", "demo_keys", "awg_configs", "amnezia_configs", "vless_configs", "admin_uploads", "user_servers", "license_keys", "rate_limits", } tx, err := pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) // Temporarily disable FK checks (requires superuser). Use savepoint so failure does not abort TX. if _, err := tx.Exec(ctx, `SAVEPOINT sp_repl_role`); err == nil { if _, err := tx.Exec(ctx, `SET LOCAL session_replication_role = replica`); err != nil { log.Printf("warn: cannot set session_replication_role: %v", err) _, _ = tx.Exec(ctx, `ROLLBACK TO SAVEPOINT sp_repl_role`) } else { _, _ = tx.Exec(ctx, `RELEASE SAVEPOINT sp_repl_role`) } } inserts := map[string][]string{} for _, m := range reInsert.FindAllStringSubmatch(text, -1) { table := strings.ToLower(m[1]) valuesPart := strings.TrimSpace(m[2]) rows, err := splitValueTuples(valuesPart) if err != nil { return fmt.Errorf("parse inserts for %s: %w", table, err) } inserts[table] = append(inserts[table], rows...) } for _, table := range tablesOrder { rows := inserts[table] if len(rows) == 0 { continue } log.Printf("importing %s (%d rows)", table, len(rows)) if err := importTable(ctx, tx, table, rows); err != nil { return fmt.Errorf("import %s: %w", table, err) } } // Fix sequences only for tables with BIGSERIAL id. // Do not run setval on wg_config_settings — it has no id column. // In PostgreSQL any failed statement aborts the whole transaction even if Go ignores the error. serialTables := []string{ "users", "site_status", "servers", "servers_simple", "countries", "wg_configs", "demo_keys", "awg_configs", "amnezia_configs", "vless_configs", "admin_uploads", "user_servers", "license_keys", "rate_limits", } for _, table := range serialTables { _, err := tx.Exec(ctx, fmt.Sprintf(` SELECT setval( pg_get_serial_sequence('%s', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM %s), 1), 1), true ) `, table, table)) if err != nil { return fmt.Errorf("fix sequence %s: %w", table, err) } } _, err = tx.Exec(ctx, ` INSERT INTO app_meta (key, value, updated_at) VALUES ('mysql_import_done', '1', NOW()) ON CONFLICT (key) DO UPDATE SET value = '1', updated_at = NOW()`) if err != nil { return fmt.Errorf("mark import done: %w", err) } if _, err := tx.Exec(ctx, `SAVEPOINT sp_repl_role_end`); err == nil { if _, err := tx.Exec(ctx, `SET LOCAL session_replication_role = DEFAULT`); err != nil { log.Printf("warn: restore session_replication_role: %v", err) _, _ = tx.Exec(ctx, `ROLLBACK TO SAVEPOINT sp_repl_role_end`) } else { _, _ = tx.Exec(ctx, `RELEASE SAVEPOINT sp_repl_role_end`) } } if err := tx.Commit(ctx); err != nil { return err } log.Println("mysql dump imported successfully") return nil } func importTable(ctx context.Context, tx pgx.Tx, table string, rows []string) error { cols, err := tableColumns(table) if err != nil { return err } for _, row := range rows { vals, err := parseRowValues(row) if err != nil { return err } if len(vals) != len(cols) { // try truncate/pad carefully if len(vals) < len(cols) { return fmt.Errorf("column count mismatch for %s: got %d want %d value=%s", table, len(vals), len(cols), truncate(row, 120)) } vals = vals[:len(cols)] } converted := make([]any, len(vals)) placeholders := make([]string, len(vals)) for i, v := range vals { converted[i] = convertValue(table, cols[i], v) placeholders[i] = fmt.Sprintf("$%d", i+1) } sql := fmt.Sprintf( `INSERT INTO %s (%s) VALUES (%s) ON CONFLICT DO NOTHING`, table, strings.Join(quoteIdent(cols), ", "), strings.Join(placeholders, ", "), ) if _, err := tx.Exec(ctx, sql, converted...); err != nil { return fmt.Errorf("insert failed (%s): %w; sample=%s", table, err, truncate(row, 160)) } } return nil } func tableColumns(table string) ([]string, error) { m := map[string][]string{ "admin_uploads": {"id", "original_name", "stored_name", "file_path", "file_url", "file_size", "uploaded_at"}, "amnezia_configs": {"id", "config_name", "original_filename", "file_path", "file_url", "config_content", "host", "port", "username", "password", "description", "created_at"}, "awg_configs": {"id", "config_name", "config_content", "qr_code_url", "created_at"}, "countries": {"id", "code", "name_ru", "name_en", "flag", "sort_order"}, "demo_keys": {"id", "config_id", "token", "expires_at", "created_at"}, "license_keys": {"id", "key_value", "server_id", "protocol", "client_id", "email", "used_by", "created_at", "expires_at"}, "rate_limits": {"id", "user_id", "action", "created_at"}, "servers": {"id", "name", "ip", "port", "panel_username", "panel_password", "status", "created_at", "updated_at", "inbound_id"}, "servers_simple": {"id", "name", "host", "port", "country", "status", "created_at"}, "site_status": {"id", "is_online", "message", "updated_at"}, "user_servers": {"id", "user_id", "server_id", "inbound_id", "protocol", "client_id", "email", "created_at"}, "users": {"id", "username", "email", "password_hash", "role", "is_approved", "created_at", "updated_at"}, "vless_configs": {"id", "vless_uri", "subscription_url", "created_at"}, "wg_config_settings": {"setting_key", "setting_value", "updated_at"}, "wg_configs": {"id", "token", "user_id", "config_content", "created_at", "original_filename", "is_demo"}, } cols, ok := m[table] if !ok { return nil, fmt.Errorf("unknown table %s", table) } return cols, nil } func convertValue(table, col, raw string) any { v := strings.TrimSpace(raw) if strings.EqualFold(v, "NULL") { return nil } if len(v) >= 2 && v[0] == '\'' && v[len(v)-1] == '\'' { v = unquoteMySQLString(v) } // MySQL dump has an empty country code for "Other" if table == "countries" && col == "code" && strings.TrimSpace(v) == "" { return "_" } boolCols := map[string]bool{ "is_approved": true, "is_online": true, "is_demo": true, } if boolCols[col] { if v == "1" || strings.EqualFold(v, "true") { return true } return false } intCols := map[string]bool{ "id": true, "user_id": true, "server_id": true, "config_id": true, "port": true, "file_size": true, "inbound_id": true, "sort_order": true, "used_by": true, } if intCols[col] { n, err := strconv.ParseInt(v, 10, 64) if err == nil { return n } } _ = table return v } func unquoteMySQLString(s string) string { s = s[1 : len(s)-1] replacer := strings.NewReplacer( `\\`, `\`, `\'`, `'`, `\"`, `"`, `\n`, "\n", `\r`, "\r", `\t`, "\t", `\0`, "\x00", `\Z`, "\x1a", ) return replacer.Replace(s) } func splitValueTuples(s string) ([]string, error) { var rows []string depth := 0 inStr := false esc := false start := -1 for i := 0; i < len(s); i++ { c := s[i] if inStr { if esc { esc = false continue } if c == '\\' { esc = true continue } if c == '\'' { // mysql '' escape if i+1 < len(s) && s[i+1] == '\'' { i++ continue } inStr = false } continue } if c == '\'' { inStr = true continue } if c == '(' { if depth == 0 { start = i + 1 } depth++ continue } if c == ')' { depth-- if depth == 0 && start >= 0 { rows = append(rows, s[start:i]) start = -1 } continue } } if depth != 0 || inStr { return nil, fmt.Errorf("unbalanced values clause") } return rows, nil } func parseRowValues(row string) ([]string, error) { var vals []string inStr := false esc := false start := 0 for i := 0; i < len(row); i++ { c := row[i] if inStr { if esc { esc = false continue } if c == '\\' { esc = true continue } if c == '\'' { if i+1 < len(row) && row[i+1] == '\'' { i++ continue } inStr = false } continue } if c == '\'' { inStr = true continue } if c == ',' { vals = append(vals, strings.TrimSpace(row[start:i])) start = i + 1 } } vals = append(vals, strings.TrimSpace(row[start:])) return vals, nil } func quoteIdent(cols []string) []string { out := make([]string, len(cols)) for i, c := range cols { out[i] = `"` + c + `"` } return out } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } // Touch for unused regex keep var _ = reCreate