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:
@@ -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 '%@%'"
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
|
||||
@@ -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<HTMLDivElement>(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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user