Skip to content

Commit 23af629

Browse files
committed
fix(storage): enforce case-insensitive uniqueness for identity_links github_user
1 parent ec48739 commit 23af629

1 file changed

Lines changed: 53 additions & 19 deletions

File tree

src/ghdcbot/adapters/storage/sqlite.py

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,35 @@ def init_schema(self) -> None:
6666
except sqlite3.OperationalError as e:
6767
if "duplicate column" not in str(e).lower():
6868
raise
69+
# Case-insensitive github_user: normalized column for uniqueness and lookups.
70+
try:
71+
conn.execute("ALTER TABLE identity_links ADD COLUMN github_user_normalized TEXT")
72+
except sqlite3.OperationalError as e:
73+
if "duplicate column" not in str(e).lower():
74+
raise
75+
conn.execute(
76+
"UPDATE identity_links SET github_user_normalized = lower(github_user) WHERE github_user_normalized IS NULL"
77+
)
78+
# De-duplicate: keep one row per (discord_user_id, github_user_normalized), prefer verified then newest.
79+
conn.execute(
80+
"""
81+
DELETE FROM identity_links
82+
WHERE rowid IN (
83+
SELECT rowid FROM (
84+
SELECT rowid,
85+
ROW_NUMBER() OVER (
86+
PARTITION BY discord_user_id, COALESCE(github_user_normalized, '')
87+
ORDER BY verified DESC, created_at DESC
88+
) AS rn
89+
FROM identity_links
90+
) WHERE rn > 1
91+
)
92+
"""
93+
)
94+
conn.execute(
95+
"CREATE UNIQUE INDEX IF NOT EXISTS idx_identity_links_discord_github_norm "
96+
"ON identity_links (discord_user_id, github_user_normalized)"
97+
)
6998
# Issue requests: contributor requests for assignment, mentor reviews
7099
conn.executescript(
71100
"""
@@ -309,15 +338,16 @@ def create_identity_claim(
309338
"""
310339
now = datetime.now(timezone.utc)
311340
expires_utc = _ensure_utc(expires_at)
341+
gh_norm = github_user.strip().lower()
312342
with self._connect() as conn:
313-
# Verified github_user owned by someone else -> reject
343+
# Verified github_user (case-insensitive) owned by someone else -> reject
314344
row = conn.execute(
315345
"""
316346
SELECT discord_user_id
317347
FROM identity_links
318-
WHERE github_user = ? AND verified = 1
348+
WHERE github_user_normalized = ? AND verified = 1
319349
""",
320-
(github_user,),
350+
(gh_norm,),
321351
).fetchone()
322352
if row and row["discord_user_id"] != discord_user_id:
323353
raise ValueError("github_user is already verified for another Discord user")
@@ -327,9 +357,9 @@ def create_identity_claim(
327357
"""
328358
SELECT discord_user_id, expires_at
329359
FROM identity_links
330-
WHERE github_user = ? AND verified = 0 AND discord_user_id != ?
360+
WHERE github_user_normalized = ? AND verified = 0 AND discord_user_id != ?
331361
""",
332-
(github_user, discord_user_id),
362+
(gh_norm, discord_user_id),
333363
).fetchall()
334364
for row in rows:
335365
existing_expires = row["expires_at"]
@@ -341,20 +371,20 @@ def create_identity_claim(
341371
conn.execute(
342372
"""
343373
DELETE FROM identity_links
344-
WHERE github_user = ? AND verified = 0 AND (expires_at IS NULL OR expires_at <= ?)
374+
WHERE github_user_normalized = ? AND verified = 0 AND (expires_at IS NULL OR expires_at <= ?)
345375
""",
346-
(github_user, now.isoformat()),
376+
(gh_norm, now.isoformat()),
347377
)
348378

349379
# Enforce one verified mapping per discord user (prevent accidental multi-link)
350380
# Exception: allow refresh if verified identity is stale
351381
row = conn.execute(
352382
"""
353-
SELECT github_user, verified, verified_at
383+
SELECT github_user, github_user_normalized, verified, verified_at
354384
FROM identity_links
355-
WHERE discord_user_id = ? AND github_user = ?
385+
WHERE discord_user_id = ? AND github_user_normalized = ?
356386
""",
357-
(discord_user_id, github_user),
387+
(discord_user_id, gh_norm),
358388
).fetchone()
359389
if row and int(row["verified"] or 0) == 1:
360390
# Check if stale
@@ -371,23 +401,24 @@ def create_identity_claim(
371401

372402
row = conn.execute(
373403
"""
374-
SELECT github_user
404+
SELECT github_user_normalized
375405
FROM identity_links
376406
WHERE discord_user_id = ? AND verified = 1
377407
""",
378408
(discord_user_id,),
379409
).fetchone()
380-
if row and row["github_user"] != github_user:
410+
if row and row["github_user_normalized"] != gh_norm:
381411
raise ValueError("discord_user_id is already verified for another GitHub user")
382412

383413
conn.execute(
384414
"""
385415
INSERT INTO identity_links (
386-
discord_user_id, github_user, verified, verification_code, expires_at, created_at, verified_at
416+
discord_user_id, github_user, github_user_normalized, verified, verification_code, expires_at, created_at, verified_at
387417
)
388-
VALUES (?, ?, 0, ?, ?, ?, NULL)
389-
ON CONFLICT(discord_user_id, github_user)
418+
VALUES (?, ?, ?, 0, ?, ?, ?, NULL)
419+
ON CONFLICT(discord_user_id, github_user_normalized)
390420
DO UPDATE SET
421+
github_user = excluded.github_user,
391422
verified = 0,
392423
verification_code = excluded.verification_code,
393424
expires_at = excluded.expires_at,
@@ -397,6 +428,7 @@ def create_identity_claim(
397428
(
398429
discord_user_id,
399430
github_user,
431+
gh_norm,
400432
verification_code,
401433
expires_utc.isoformat(),
402434
now.isoformat(),
@@ -405,20 +437,22 @@ def create_identity_claim(
405437

406438
def get_identity_link(self, discord_user_id: str, github_user: str) -> dict | None:
407439
self.init_schema()
440+
gh_norm = github_user.strip().lower()
408441
with self._connect() as conn:
409442
row = conn.execute(
410443
"""
411444
SELECT discord_user_id, github_user, verified, verification_code,
412445
expires_at, created_at, verified_at, unlinked_at
413446
FROM identity_links
414-
WHERE discord_user_id = ? AND github_user = ?
447+
WHERE discord_user_id = ? AND github_user_normalized = ?
415448
""",
416-
(discord_user_id, github_user),
449+
(discord_user_id, gh_norm),
417450
).fetchone()
418451
return dict(row) if row else None
419452

420453
def mark_identity_verified(self, discord_user_id: str, github_user: str) -> None:
421454
now = datetime.now(timezone.utc).isoformat()
455+
gh_norm = github_user.strip().lower()
422456
with self._connect() as conn:
423457
conn.execute(
424458
"""
@@ -427,9 +461,9 @@ def mark_identity_verified(self, discord_user_id: str, github_user: str) -> None
427461
verified_at = ?,
428462
verification_code = NULL,
429463
expires_at = NULL
430-
WHERE discord_user_id = ? AND github_user = ?
464+
WHERE discord_user_id = ? AND github_user_normalized = ?
431465
""",
432-
(now, discord_user_id, github_user),
466+
(now, discord_user_id, gh_norm),
433467
)
434468

435469
def unlink_identity(

0 commit comments

Comments
 (0)