UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

196 lines (171 loc) 6.52 kB
import { $inject, AlephaError } from "alepha"; import { $logger } from "alepha/logger"; import { RealmProvider } from "../providers/RealmProvider.ts"; /** * Derive stable, URL-safe usernames from email addresses. * * Used by the registration flow when `realm.settings.username === "email"`, * and reusable from any custom user-creation site (e.g. an OAuth-only flow * that wants the same handle convention). * * **Slug rule** — the local-part of the email is kept as-is (gmail * `+suffix` retained for predictability). Everything outside `[a-z0-9]` is * replaced with `-`, runs of `-` are collapsed, leading/trailing `-` are * trimmed, lowercased. If the result is shorter than {@link MIN_LENGTH} it * is padded with random alphanumerics. Result is clamped to * {@link MAX_LENGTH}. * * **Collision retry** — `pickAvailable()` checks the realm's `users` table * and the realm's `usernameBlocklist`. On hit, it appends `-<4 random>` and * retries up to {@link MAX_RETRIES} times. Best-effort against concurrent * registrations: the unique index on `(realm, lower(username))` is the * authoritative race guard, callers that hit a unique-violation should * call `pickAvailable` again with a fresh suffix. * * @see RegistrationService.createRegistrationIntent */ export class UsernameSlugger { protected readonly realmProvider = $inject(RealmProvider); protected readonly log = $logger(); /** * Floor for derived usernames. Shorter slugs are padded with random * alphanumerics. Matches the lower bound of the default `usernameRegExp`. */ static readonly MIN_LENGTH = 3; /** * Ceiling for derived usernames. Matches the upper bound of the default * `usernameRegExp` and gives enough headroom for the random suffix added * on collisions. */ static readonly MAX_LENGTH = 30; /** * Length of the random suffix appended on collision (e.g. `-3dp6`). * 36⁴ ≈ 1.6M variants per base — plenty for a tiny number of retries. */ static readonly SUFFIX_LENGTH = 4; /** * How many times `pickAvailable` retries before giving up. */ static readonly MAX_RETRIES = 5; /** * Alphabet used for the random suffix and for padding short slugs. */ static readonly ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"; /** * Default replacement when the email's local-part contains no * `[a-z0-9]` characters at all (rare but possible: `é@example.com`). */ static readonly EMPTY_LOCAL_FALLBACK = "user"; /** * Sanitize an email into a base username slug. * * Pure function — no DB access. Always returns a string that satisfies * `^[a-z0-9-]{MIN_LENGTH,MAX_LENGTH}$`. * * @example * slug("ni.foures+testkv@gmail.com") // "ni-foures-testkv" * slug("john.doe@example.com") // "john-doe" * slug("é@example.com") // "user-XXX" (padded) */ public slug(email: string | null | undefined): string { const raw = (email ?? "").trim(); const at = raw.indexOf("@"); const local = at > 0 ? raw.slice(0, at) : raw; const cleaned = local .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); let result = cleaned || UsernameSlugger.EMPTY_LOCAL_FALLBACK; if (result.length < UsernameSlugger.MIN_LENGTH) { const needed = UsernameSlugger.MIN_LENGTH - result.length; result += this.randomSuffix(needed); } return this.clamp(result); } /** * Find an available username for the realm, starting from `base`. * * Returns `base` when nothing collides. On a hit (existing row OR * blocklisted name) appends `-<4 random>` and tries again, up to * {@link MAX_RETRIES} times. * * The check is best-effort: a concurrent registration may still claim * the same value before the caller's INSERT runs, in which case the DB * unique index throws and the caller should retry. */ public async pickAvailable( realmName: string | undefined, base: string, ): Promise<string> { const blocklist = await this.getBlocklist(realmName); const repo = this.realmProvider.userRepository(realmName); const realm = this.realmProvider.getRealm(realmName); const isAvailable = async (candidate: string): Promise<boolean> => { if (this.isBlockedAgainst(candidate, blocklist)) { return false; } const existing = await repo.findOne({ where: { realm: { eq: realm.name }, username: { ilike: candidate }, }, }); return !existing; }; if (await isAvailable(base)) { return base; } // Reserve room for "-" + suffix at the end of the candidate. const reserve = 1 + UsernameSlugger.SUFFIX_LENGTH; const trimmedBase = base.length > UsernameSlugger.MAX_LENGTH - reserve ? base.slice(0, UsernameSlugger.MAX_LENGTH - reserve) : base; for (let i = 0; i < UsernameSlugger.MAX_RETRIES; i++) { const candidate = `${trimmedBase}-${this.randomSuffix( UsernameSlugger.SUFFIX_LENGTH, )}`; if (await isAvailable(candidate)) { return candidate; } } throw new AlephaError( `Could not find an available username starting from "${base}" after ${UsernameSlugger.MAX_RETRIES} attempts.`, ); } /** * Check a name against the realm's `usernameBlocklist`. Case-insensitive. */ public async isBlocked( realmName: string | undefined, name: string, ): Promise<boolean> { const blocklist = await this.getBlocklist(realmName); return this.isBlockedAgainst(name, blocklist); } // ------------------------------------------------------------------------- protected async getBlocklist( realmName: string | undefined, ): Promise<Set<string>> { const realm = this.realmProvider.getRealm(realmName); const settings = await realm.getSettings(); const list = settings?.usernameBlocklist ?? []; return new Set(list.map((b) => b.toLowerCase())); } protected isBlockedAgainst(name: string, blocklist: Set<string>): boolean { return blocklist.has(name.toLowerCase()); } protected clamp(s: string): string { return s.length > UsernameSlugger.MAX_LENGTH ? s.slice(0, UsernameSlugger.MAX_LENGTH) : s; } protected randomSuffix(length: number): string { const chars = UsernameSlugger.ALPHABET; let out = ""; for (let i = 0; i < length; i++) { out += chars[Math.floor(Math.random() * chars.length)]; } return out; } }