fix: case-insensitive usernames and federated space ownership detection

Enforce lowercase usernames at registration/login, migrate existing
usernames, and fix ownership checks for federated spaces by resolving
user identity per-instance with getMyUserIdForOrigin.
This commit is contained in:
Jannis Braun
2026-03-11 18:09:45 +01:00
parent 77ed2a70c4
commit 373d685a55
5 changed files with 50 additions and 19 deletions
+32
View File
@@ -204,6 +204,9 @@ export function runMigrations(db: Database.Database): void {
// ─── Clean up orphaned data from deleted users and channels ────────────────
migrateOrphanedData(db);
// ─── Lowercase all existing usernames ────────────────────────────────────────
migrateLowercaseUsernames(db);
console.log('Migrations complete.');
}
@@ -545,6 +548,35 @@ function migrateOrphanedData(db: Database.Database): void {
* Frees plain usernames for native user creation and makes all federated users
* visually consistent. Safe because JWTs validate by userId, not username.
*/
/**
* Lowercase all existing native usernames. Skips federated users (contain @)
* and tombstoned users (!deleted: prefix). If lowercasing would cause a collision,
* skip that user to avoid data loss.
*/
function migrateLowercaseUsernames(db: Database.Database): void {
const rows = db.prepare(
"SELECT id, username FROM users WHERE username NOT LIKE '%@%' AND username NOT LIKE '!deleted:%'"
).all() as { id: string; username: string }[];
const needsUpdate = rows.filter(r => r.username !== r.username.toLowerCase());
if (needsUpdate.length === 0) return;
const checkExisting = db.prepare('SELECT id FROM users WHERE username = ?');
const update = db.prepare('UPDATE users SET username = ? WHERE id = ?');
for (const row of needsUpdate) {
const lower = row.username.toLowerCase();
// Check for collision (another user already has the lowercase version)
const existing = checkExisting.get(lower) as { id: string } | undefined;
if (existing && existing.id !== row.id) {
console.log(`Migrating: Skipping lowercase of "${row.username}" — "${lower}" already taken by user ${existing.id}`);
continue;
}
update.run(lower, row.id);
console.log(`Migrating: Lowercased username "${row.username}" → "${lower}"`);
}
}
function migrateReplicatedUsernames(db: Database.Database): void {
const rows = db.prepare(
"SELECT id, username, home_instance FROM users WHERE home_instance IS NOT NULL AND username NOT LIKE '%@%'"
+9 -9
View File
@@ -28,7 +28,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Password is required', statusCode: 400 });
}
const trimmedUsername = username.trim();
const trimmedUsername = username.trim().toLowerCase();
// Replicated registrations (homeInstance provided) may use username@domain format
// for collision fallback. Local registrations use strict alphanumeric+underscore.
@@ -44,8 +44,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const localPart = trimmedUsername.slice(0, atIndex);
const domainPart = trimmedUsername.slice(atIndex + 1);
if (localPart.length < 3 || localPart.length > 32 || !/^[a-zA-Z0-9_]+$/.test(localPart)) {
return reply.code(400).send({ error: 'Username local part must be 3-32 alphanumeric/underscore characters', statusCode: 400 });
if (localPart.length < 3 || localPart.length > 32 || !/^[a-z0-9_]+$/.test(localPart)) {
return reply.code(400).send({ error: 'Username local part must be 3-32 lowercase alphanumeric/underscore characters', statusCode: 400 });
}
if (domainPart.length === 0 || domainPart.length > 253 || !/^[a-zA-Z0-9._-]+$/.test(domainPart)) {
return reply.code(400).send({ error: 'Username domain part is invalid', statusCode: 400 });
@@ -63,8 +63,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
if (trimmedUsername.length < 3 || trimmedUsername.length > 32) {
return reply.code(400).send({ error: 'Username must be between 3 and 32 characters', statusCode: 400 });
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain letters, numbers, and underscores', statusCode: 400 });
if (!/^[a-z0-9_]+$/.test(trimmedUsername)) {
return reply.code(400).send({ error: 'Username can only contain lowercase letters, numbers, and underscores', statusCode: 400 });
}
}
@@ -142,14 +142,14 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ available: false, reason: 'Username is required' });
}
const trimmed = raw.trim();
const trimmed = raw.trim().toLowerCase();
// Format validation (same rules as registration)
if (trimmed.length < 3 || trimmed.length > 32) {
return reply.code(200).send({ available: false, reason: 'Username must be between 3 and 32 characters' });
}
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
return reply.code(200).send({ available: false, reason: 'Username can only contain letters, numbers, and underscores' });
if (!/^[a-z0-9_]+$/.test(trimmed)) {
return reply.code(200).send({ available: false, reason: 'Username can only contain lowercase letters, numbers, and underscores' });
}
// Check registration is open
@@ -187,7 +187,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim())).get();
const user = db.select().from(schema.users).where(eq(schema.users.username, username.trim().toLowerCase())).get();
if (!user) {
return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 });
}