@@ -64,6 +64,41 @@ const CACHE_TTL_SECONDS = 15 * 60;
6464// backends without splitting the cap by driver.
6565const BULK_QUERY_CHUNK_SIZE = 200 ;
6666
67+ // The `email`, `clean_email`, and `username` columns are latin1_swedish_ci.
68+ // MySQL throws ER_CANT_AGGREGATE_2COLLATIONS on `=` when a utf8mb4 param
69+ // contains any character > U+00FF, since the implicit conversion to latin1
70+ // would lose data. No stored row can match such a value anyway, so we
71+ // short-circuit these lookups at the boundary instead of letting the driver
72+ // surface the collation error.
73+ const isStorableAsLatin1 = ( value : string ) : boolean => {
74+ for ( let i = 0 ; i < value . length ; i ++ ) {
75+ if ( value . charCodeAt ( i ) > 0xff ) return false ;
76+ }
77+ return true ;
78+ } ;
79+
80+ // Columns on the `user` table that are stored as latin1_swedish_ci. Used by
81+ // both the read path (skip the DB on un-storable lookups) and the write path
82+ // (reject inserts/updates before MySQL throws on conversion).
83+ const LATIN1_USER_COLUMNS : ReadonlySet < string > = new Set ( [
84+ 'email' ,
85+ 'username' ,
86+ 'clean_email' ,
87+ ] ) ;
88+
89+ const assertLatin1Writable = ( fields : Record < string , unknown > ) : void => {
90+ for ( const [ key , value ] of Object . entries ( fields ) ) {
91+ if ( ! LATIN1_USER_COLUMNS . has ( key ) ) continue ;
92+ if ( typeof value !== 'string' ) continue ;
93+ if ( isStorableAsLatin1 ( value ) ) continue ;
94+ const err = new Error (
95+ `User field '${ key } ' contains characters outside latin1` ,
96+ ) ;
97+ ( err as { code ?: string } ) . code = 'userFieldNotLatin1' ;
98+ throw err ;
99+ }
100+ } ;
101+
67102// ── UserStore ────────────────────────────────────────────────────────
68103
69104/**
@@ -190,6 +225,7 @@ export class UserStore extends PuterStore {
190225 */
191226 async getByCleanEmail ( cleanEmailValue : string ) : Promise < UserRow | null > {
192227 if ( ! cleanEmailValue ) return null ;
228+ if ( ! isStorableAsLatin1 ( cleanEmailValue ) ) return null ;
193229 const rows = ( await this . clients . db . tryHardRead (
194230 'SELECT `id` FROM `user` WHERE `clean_email` = ? LIMIT 1' ,
195231 [ cleanEmailValue ] ,
@@ -219,6 +255,16 @@ export class UserStore extends PuterStore {
219255 if ( hit ) return hit ;
220256 }
221257
258+ // Reject lookup values that can't exist in a latin1 column before
259+ // the driver turns them into a collation-mix error at MySQL.
260+ if (
261+ LATIN1_USER_COLUMNS . has ( prop ) &&
262+ typeof value === 'string' &&
263+ ! isStorableAsLatin1 ( value )
264+ ) {
265+ return null ;
266+ }
267+
222268 // Replication-aware read: on `force`, go straight to the primary
223269 // (`pread`) to bypass replica lag for hot reads (e.g., immediately
224270 // after a signup). Otherwise `tryHardRead` parallels primary +
@@ -270,6 +316,7 @@ export class UserStore extends PuterStore {
270316 referrer ?: string | null ;
271317 last_activity_ts ?: string | null ;
272318 } ) : Promise < UserRow > {
319+ assertLatin1Writable ( fields as Record < string , unknown > ) ;
273320 const result = await this . clients . db . write (
274321 `INSERT INTO \`user\`
275322 (username,
@@ -336,6 +383,8 @@ export class UserStore extends PuterStore {
336383 const keys = Object . keys ( patch ) ;
337384 if ( keys . length === 0 ) return ;
338385
386+ assertLatin1Writable ( patch ) ;
387+
339388 const setClause = keys . map ( ( k ) => `\`${ k } \` = ?` ) . join ( ', ' ) ;
340389 const values = keys . map ( ( k ) => patch [ k ] ) ;
341390
0 commit comments