fix: convert MySQL logbook to utf8mb4 (fixes 1366 on Cyrillic/Polish)

A QSO with a non-Latin character (Cyrillic Я = \xD0\xAF, Polish ł, …) failed to
save on the shared MySQL backend with "Error 1366: Incorrect string value …
for column 'qso'.'address'". Cause: the database had been pre-created by a
hosting panel (o2switch/cPanel) as latin1, so OpsLog's CREATE DATABASE IF NOT
EXISTS … utf8mb4 was a no-op and every table inherited latin1 — which can't
store those letters, even over a utf8mb4 connection.

OpenMySQL now runs ensureMySQLUTF8MB4 after migrations: ALTER DATABASE to
utf8mb4 (for future tables) + CONVERT any table with a non-utf8mb4 text column
via an information_schema probe. Idempotent and cheap on a healthy DB (the probe
returns nothing → no ALTERs); best-effort so a charset issue never blocks
logbook access. SQLite is unaffected.
This commit is contained in:
2026-07-23 23:59:47 +02:00
parent 1a0a349fb3
commit 75510a1161
2 changed files with 51 additions and 0 deletions
+49
View File
@@ -207,9 +207,58 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
Dialect = "sqlite" // migration failed; we'll fall back to SQLite
return nil, err
}
// Guarantee the schema can store non-Latin text (Cyrillic, Polish ł, …).
// Best-effort: never block access to the logbook over the charset probe.
if err := ensureMySQLUTF8MB4(conn, name); err != nil {
fmt.Printf("OpsLog: utf8mb4 conversion: %v\n", err)
}
return conn, nil
}
// ensureMySQLUTF8MB4 makes the database default and every OpsLog table utf8mb4,
// converting any that isn't. It repairs databases pre-created as latin1 or
// utf8mb3 by a hosting panel (cPanel/o2switch default), where OpsLog's
// "CREATE DATABASE IF NOT EXISTS … utf8mb4" was a no-op and the tables inherited
// a charset that can't store non-Latin callsigns/names/QTHs — the MySQL 1366
// "Incorrect string value: '\xD0\xAF'" (Cyrillic Я) seen updating a QSO from QRZ.
//
// Idempotent and cheap on a healthy database: one information_schema probe finds
// the tables with a non-utf8mb4 text column, and only those are converted (none
// on an already-correct DB). Conversion is safe for the wide qso table because
// the bulk of its columns are off-page TEXT (see the row-size note above); only
// the ~20 indexed VARCHARs sit in-row, well under InnoDB's 65535-byte limit even
// at 4 bytes/char.
func ensureMySQLUTF8MB4(conn *sql.DB, dbName string) error {
// Fix the database default so any FUTURE table is utf8mb4 too.
_, _ = conn.Exec("ALTER DATABASE `" + dbName + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
rows, err := conn.Query(`
SELECT DISTINCT TABLE_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ?
AND CHARACTER_SET_NAME IS NOT NULL
AND CHARACTER_SET_NAME <> 'utf8mb4'`, dbName)
if err != nil {
return fmt.Errorf("probe charsets: %w", err)
}
var tables []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err == nil && validDBIdent(t) {
tables = append(tables, t)
}
}
rows.Close()
var firstErr error
for _, t := range tables {
if _, err := conn.Exec("ALTER TABLE `" + t + "` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil && firstErr == nil {
firstErr = fmt.Errorf("convert %s: %w", t, err)
}
}
return firstErr
}
// isFreshMySQL reports whether the database has no OpsLog schema yet (no qso
// table), so the baseline fast-path applies. A partially-migrated database is
// NOT fresh and goes through the incremental migrator.