Files
wg/internal/mysqlimport/import.go
T

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