diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 2715bf34..bb78929c 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -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 '%@%'" diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index d33a4e8c..7978ae04 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -28,7 +28,7 @@ export async function authRoutes(app: FastifyInstance): Promise { 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 { 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 { 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 { 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 { 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 }); } diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 77a0cb34..5f220586 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -71,9 +71,9 @@ export function RegisterPage() { return; } - if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { + if (!/^[a-z0-9_]+$/.test(trimmed)) { setUsernameStatus('invalid'); - setUsernameStatusMessage('Username can only contain letters, numbers, and underscores'); + setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores'); return; } @@ -141,8 +141,8 @@ export function RegisterPage() { setError('Username must be between 3 and 32 characters'); return; } - if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) { - setError('Username can only contain letters, numbers, and underscores'); + if (!/^[a-z0-9_]+$/.test(trimmed)) { + setError('Username can only contain lowercase letters, numbers, and underscores'); return; } if (usernameStatus === 'taken' || usernameStatus === 'invalid') { @@ -250,7 +250,7 @@ export function RegisterPage() { setUsername(e.target.value)} + onChange={(e) => setUsername(e.target.value.toLowerCase())} className="w-full px-3 py-2.5 bg-surface-input border-none rounded text-txt-primary outline-none focus:ring-2 focus:ring-accent-primary transition-all" autoFocus autoComplete="username" diff --git a/packages/web/src/components/layout/SpaceSidebar.tsx b/packages/web/src/components/layout/SpaceSidebar.tsx index 98055e3b..5d6c4f62 100644 --- a/packages/web/src/components/layout/SpaceSidebar.tsx +++ b/packages/web/src/components/layout/SpaceSidebar.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import ReactDOM from 'react-dom'; import { useNavigate, useLocation } from 'react-router-dom'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useChatStore } from '../../stores/chatStore'; import { useUIStore } from '../../stores/uiStore'; import { useInstanceStore } from '../../stores/instanceStore'; @@ -167,7 +167,6 @@ function InstanceDivider({ label, disconnected }: { label: string; disconnected: function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: number; y: number; onClose: () => void }) { const menuRef = useRef(null); const space = useSpaceStore((s) => s.spaces.find(sp => sp.id === spaceId)); - const currentUserId = useAuthStore((s) => s.user?.id); const leaveSpace = useSpaceStore((s) => s.leaveSpace); const generateInvite = useSpaceStore((s) => s.generateInvite); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); @@ -177,7 +176,7 @@ function SpaceContextMenu({ spaceId, x, y, onClose }: { spaceId: string; x: numb const navigate = useNavigate(); const [showTransferModal, setShowTransferModal] = useState(false); - const isOwner = space?.ownerId === currentUserId; + const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? ''); // Close on click-outside and scroll useEffect(() => { diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 39242b1f..a0b2ea61 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -6,7 +6,7 @@ import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useNavigate } from 'react-router-dom'; import { api } from '../../../api/client'; -import { getApiForOrigin } from '../../../stores/spaceStore'; +import { getApiForOrigin, getMyUserIdForOrigin } from '../../../stores/spaceStore'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; interface OverviewPanelProps { @@ -23,7 +23,7 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const navigate = useNavigate(); const space = spaces.find((s) => s.id === spaceId); - const isOwner = space?.ownerId === currentUser?.id; + const isOwner = space?.ownerId === getMyUserIdForOrigin((space as any)?._instanceOrigin ?? ''); const myPerms = spacePermissions.get(spaceId); const canManageSpace = hasPermissionBit(myPerms, PermissionBits.MANAGE_SPACE);