Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
222 lines
6.0 KiB
Go
222 lines
6.0 KiB
Go
// Copyright © 2026 Ory Corp
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package migrate
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
"text/tabwriter"
|
|
"time"
|
|
|
|
"github.com/gofrs/uuid"
|
|
"github.com/pkg/errors"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/ory/kratos/driver"
|
|
"github.com/ory/kratos/x"
|
|
"github.com/ory/pop/v6"
|
|
"github.com/ory/x/flagx"
|
|
)
|
|
|
|
// phonePattern matches values that look like phone numbers: + followed by at least one digit,
|
|
// possibly with spaces, dashes, or parens. This filters out OIDC subjects and other
|
|
// identifiers that happen to start with +.
|
|
var phonePattern = regexp.MustCompile(`^\+[\d\s\-().]+$`)
|
|
|
|
type NormalizePhoneHandler struct{}
|
|
|
|
func NewNormalizePhoneHandler() *NormalizePhoneHandler {
|
|
return &NormalizePhoneHandler{}
|
|
}
|
|
|
|
type normalizeStats struct {
|
|
scanned int
|
|
updated int
|
|
skipped int
|
|
errors int
|
|
}
|
|
|
|
func (h *NormalizePhoneHandler) NormalizePhoneNumbers(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
|
|
d, err := getPersister(cmd, args, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
conn := d.Persister().GetConnection(cmd.Context())
|
|
batchSize := flagx.MustGetInt(cmd, "batch-size")
|
|
dryRun := flagx.MustGetBool(cmd, "dry-run")
|
|
batchDelay := flagx.MustGetDuration(cmd, "batch-delay")
|
|
|
|
if dryRun {
|
|
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Dry run mode enabled. No changes will be written.")
|
|
}
|
|
|
|
tables := []tableConfig{
|
|
{
|
|
key: "ci",
|
|
name: "Credential identifiers",
|
|
selectQuery: `
|
|
SELECT id, identifier AS value
|
|
FROM identity_credential_identifiers
|
|
WHERE identifier LIKE '+%'
|
|
AND id > ?
|
|
ORDER BY id ASC LIMIT ?`,
|
|
updateQuery: `UPDATE identity_credential_identifiers SET identifier = ?, updated_at = ? WHERE id = ? AND identifier = ?`,
|
|
},
|
|
{
|
|
key: "va",
|
|
name: "Verifiable addresses",
|
|
selectQuery: `
|
|
SELECT id, value
|
|
FROM identity_verifiable_addresses
|
|
WHERE via = 'sms'
|
|
AND id > ?
|
|
AND value LIKE '+%'
|
|
ORDER BY id ASC LIMIT ?`,
|
|
updateQuery: `UPDATE identity_verifiable_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
|
|
},
|
|
{
|
|
key: "ra",
|
|
name: "Recovery addresses",
|
|
selectQuery: `
|
|
SELECT id, value
|
|
FROM identity_recovery_addresses
|
|
WHERE via = 'sms'
|
|
AND id > ?
|
|
AND value LIKE '+%'
|
|
ORDER BY id ASC LIMIT ?`,
|
|
updateQuery: `UPDATE identity_recovery_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
|
|
},
|
|
}
|
|
|
|
startAfterMap, err := parseStartAfter(flagx.MustGetStringSlice(cmd, "start-after"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
allStats := make([]normalizeStats, len(tables))
|
|
for i, table := range tables {
|
|
startAfter := startAfterMap[table.key]
|
|
if startAfter != uuid.Nil {
|
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: resuming after ID %s\n", table.name, startAfter)
|
|
}
|
|
stats, err := normalizeTable(conn, batchSize, batchDelay, startAfter, dryRun, cmd, table)
|
|
if err != nil {
|
|
return errors.Wrapf(err, "normalizing %s", table.name)
|
|
}
|
|
allStats[i] = stats
|
|
}
|
|
|
|
printSummary(cmd, tables, allStats)
|
|
|
|
return nil
|
|
}
|
|
|
|
type tableConfig struct {
|
|
key string
|
|
name string
|
|
selectQuery string
|
|
updateQuery string
|
|
}
|
|
|
|
// parseStartAfter parses --start-after flags of the form "key=uuid".
|
|
func parseStartAfter(args []string) (map[string]uuid.UUID, error) {
|
|
result := make(map[string]uuid.UUID)
|
|
for _, arg := range args {
|
|
key, val, ok := strings.Cut(arg, "=")
|
|
if !ok {
|
|
return nil, errors.Errorf("invalid --start-after format %q: expected key=uuid (e.g. credentials=<id>)", arg)
|
|
}
|
|
id, err := uuid.FromString(val)
|
|
if err != nil {
|
|
return nil, errors.Wrapf(err, "invalid UUID in --start-after %q", arg)
|
|
}
|
|
result[key] = id
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func printSummary(cmd *cobra.Command, tables []tableConfig, allStats []normalizeStats) {
|
|
out := cmd.OutOrStdout()
|
|
|
|
_, _ = fmt.Fprintln(out)
|
|
|
|
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
|
_, _ = fmt.Fprintln(tw, "\tSCANNED\tUPDATED\tSKIPPED\tERRORS")
|
|
|
|
var total normalizeStats
|
|
for i, table := range tables {
|
|
s := allStats[i]
|
|
_, _ = fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\n", table.name, s.scanned, s.updated, s.skipped, s.errors)
|
|
total.scanned += s.scanned
|
|
total.updated += s.updated
|
|
total.skipped += s.skipped
|
|
total.errors += s.errors
|
|
}
|
|
_, _ = fmt.Fprintf(tw, "TOTAL\t%d\t%d\t%d\t%d\n", total.scanned, total.updated, total.skipped, total.errors)
|
|
_ = tw.Flush()
|
|
}
|
|
|
|
func normalizeTable(conn *pop.Connection, batchSize int, batchDelay time.Duration, startAfter uuid.UUID, dryRun bool, cmd *cobra.Command, table tableConfig) (normalizeStats, error) {
|
|
var stats normalizeStats
|
|
lastID := startAfter
|
|
|
|
for {
|
|
var rows []struct {
|
|
ID uuid.UUID `db:"id"`
|
|
Value string `db:"value"`
|
|
}
|
|
|
|
if err := conn.RawQuery(table.selectQuery, lastID, batchSize).All(&rows); err != nil {
|
|
return stats, errors.Wrapf(err, "querying %s", table.name)
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, row := range rows {
|
|
lastID = row.ID
|
|
stats.scanned++
|
|
|
|
// The SQL query pre-filters with LIKE '+%'. This Go-side regex additionally
|
|
// rejects non-phone identifiers (e.g. OIDC subjects) that happen to start
|
|
// with '+'. We cannot use SQL regex because SQLite does not support it.
|
|
if !phonePattern.MatchString(row.Value) {
|
|
stats.skipped++
|
|
continue
|
|
}
|
|
|
|
normalized := x.NormalizePhoneIdentifier(row.Value)
|
|
if normalized == row.Value {
|
|
stats.skipped++
|
|
continue
|
|
}
|
|
|
|
if dryRun {
|
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[dry-run] %s %s: %q -> %q\n", table.name, row.ID, row.Value, normalized)
|
|
stats.updated++
|
|
continue
|
|
}
|
|
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
if err := conn.RawQuery(table.updateQuery, normalized, now, row.ID, row.Value).Exec(); err != nil {
|
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "error updating %s %s (%q -> %q): %v\n", table.name, row.ID, row.Value, normalized, err)
|
|
stats.errors++
|
|
continue
|
|
}
|
|
stats.updated++
|
|
}
|
|
|
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\tprocessed %d rows so far (--start-after %s=%s)\n", table.name, stats.scanned, table.key, lastID)
|
|
|
|
if batchDelay > 0 {
|
|
time.Sleep(batchDelay)
|
|
}
|
|
}
|
|
|
|
return stats, nil
|
|
}
|