343 lines
8.4 KiB
Go
343 lines
8.4 KiB
Go
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 via deferred constraints where possible
|
|
if _, err := tx.Exec(ctx, `SET session_replication_role = replica`); err != nil {
|
|
log.Printf("warn: cannot set session_replication_role: %v", err)
|
|
}
|
|
|
|
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
|
|
for _, table := range tablesOrder {
|
|
_, _ = tx.Exec(ctx, fmt.Sprintf(`
|
|
SELECT setval(pg_get_serial_sequence('%s','id'), COALESCE((SELECT MAX(id) FROM %s), 1), true)
|
|
`, table, table))
|
|
}
|
|
|
|
_, 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 err
|
|
}
|
|
|
|
if _, err := tx.Exec(ctx, `SET session_replication_role = DEFAULT`); err != nil {
|
|
log.Printf("warn: restore session_replication_role: %v", err)
|
|
}
|
|
|
|
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
|