diff --git a/fluxer_api/src/auth/AuthController.ts b/fluxer_api/src/auth/AuthController.ts index cdd6b622..5c27b986 100644 --- a/fluxer_api/src/auth/AuthController.ts +++ b/fluxer_api/src/auth/AuthController.ts @@ -412,7 +412,7 @@ export const AuthController = (app: HonoApp) => { Validator('json', UsernameSuggestionsRequest), async (ctx) => { const {global_name} = ctx.req.valid('json'); - const suggestions = generateUsernameSuggestions(global_name, 5); + const suggestions = generateUsernameSuggestions(global_name); return ctx.json({suggestions}); }, ); diff --git a/fluxer_api/src/auth/services/AuthRegistrationService.ts b/fluxer_api/src/auth/services/AuthRegistrationService.ts index 22441b4e..78d44e8c 100644 --- a/fluxer_api/src/auth/services/AuthRegistrationService.ts +++ b/fluxer_api/src/auth/services/AuthRegistrationService.ts @@ -45,6 +45,7 @@ import * as AgeUtils from '~/utils/AgeUtils'; import * as IpUtils from '~/utils/IpUtils'; import {parseAcceptLanguage} from '~/utils/LocaleUtils'; import {generateRandomUsername} from '~/utils/UsernameGenerator'; +import {deriveUsernameFromDisplayName} from '~/utils/UsernameSuggestionUtils'; const MINIMUM_AGE_BY_COUNTRY: Record = { KR: 14, @@ -202,8 +203,32 @@ export class AuthRegistrationService { if (emailTaken) throw InputValidationError.create('email', 'Email already in use'); } - const username = data.username || generateRandomUsername(); - const discriminator = await this.allocateDiscriminator(username); + let usernameCandidate: string | undefined = data.username ?? undefined; + let discriminator: number | null = null; + + if (!usernameCandidate) { + const derivedUsername = deriveUsernameFromDisplayName(data.global_name ?? ''); + if (derivedUsername) { + try { + discriminator = await this.allocateDiscriminator(derivedUsername); + usernameCandidate = derivedUsername; + } catch (error) { + if (!(error instanceof InputValidationError)) { + throw error; + } + } + } + } + + if (!usernameCandidate) { + usernameCandidate = generateRandomUsername(); + discriminator = await this.allocateDiscriminator(usernameCandidate); + } else if (discriminator === null) { + discriminator = await this.allocateDiscriminator(usernameCandidate); + } + + const username = usernameCandidate!; + const userId = this.generateUserId(emailKey); const acceptLanguage = request.headers.get('accept-language'); diff --git a/fluxer_api/src/utils/UsernameSuggestionUtils.ts b/fluxer_api/src/utils/UsernameSuggestionUtils.ts index 1dbaf26e..6df12a99 100644 --- a/fluxer_api/src/utils/UsernameSuggestionUtils.ts +++ b/fluxer_api/src/utils/UsernameSuggestionUtils.ts @@ -17,64 +17,36 @@ * along with Fluxer. If not, see . */ +import {UsernameType} from '~/Schema'; import {transliterate as tr} from 'transliteration'; -import {generateRandomUsername} from '~/utils/UsernameGenerator'; -function sanitizeForFluxerTag(input: string): string { - let result = tr(input.trim()); +const MAX_USERNAME_LENGTH = 32; - result = result.replace(/[\s\-.]+/g, '_'); +function sanitizeDisplayName(globalName: string): string | null { + const trimmed = globalName.trim(); + if (!trimmed) return null; - result = result.replace(/[^a-zA-Z0-9_]/g, ''); - - if (!result) { - result = 'user'; + let sanitized = tr(trimmed); + sanitized = sanitized.replace(/[\s\-.]+/g, '_'); + sanitized = sanitized.replace(/[^a-zA-Z0-9_]/g, ''); + if (!sanitized) return null; + if (sanitized.length > MAX_USERNAME_LENGTH) { + sanitized = sanitized.substring(0, MAX_USERNAME_LENGTH); } - if (result.length > 32) { - result = result.substring(0, 32); + const validation = UsernameType.safeParse(sanitized); + if (!validation.success) { + return null; } - return result.toLowerCase(); + return sanitized; } -export function generateUsernameSuggestions(globalName: string, count: number = 5): Array { - const suggestions: Array = []; - - const transliterated = tr(globalName.trim()); - const hasMeaningfulContent = /[a-zA-Z]/.test(transliterated); - - if (!hasMeaningfulContent) { - for (let i = 0; i < count; i++) { - const randomUsername = generateRandomUsername(); - const sanitizedRandom = sanitizeForFluxerTag(randomUsername); - if (sanitizedRandom && sanitizedRandom.length <= 32) { - suggestions.push(sanitizedRandom.toLowerCase()); - } - } - return Array.from(new Set(suggestions)).slice(0, count); - } - - const baseUsername = sanitizeForFluxerTag(globalName); - suggestions.push(baseUsername); - - const suffixes = ['_', '__', '___', '123', '_1', '_official', '_real']; - for (const suffix of suffixes) { - if (suggestions.length >= count) break; - const suggestion = baseUsername + suffix; - if (suggestion.length <= 32) { - suggestions.push(suggestion); - } - } - - let counter = 2; - while (suggestions.length < count) { - const suggestion = `${baseUsername}${counter}`; - if (suggestion.length <= 32) { - suggestions.push(suggestion); - } - counter++; - } - - return Array.from(new Set(suggestions)).slice(0, count); +export function deriveUsernameFromDisplayName(globalName: string): string | null { + return sanitizeDisplayName(globalName); +} + +export function generateUsernameSuggestions(globalName: string): Array { + const candidate = deriveUsernameFromDisplayName(globalName); + return candidate ? [candidate] : []; } diff --git a/tests/integration/auth_register_display_name_autogen_test.go b/tests/integration/auth_register_display_name_autogen_test.go new file mode 100644 index 00000000..f688993a --- /dev/null +++ b/tests/integration/auth_register_display_name_autogen_test.go @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2026 Fluxer Contributors + * + * This file is part of Fluxer. + * + * Fluxer is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Fluxer is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Fluxer. If not, see . + */ + +package integration + +import ( + "fmt" + "net/http" + "testing" + "time" +) + +func TestAuthRegisterDerivesUsernameFromDisplayName(t *testing.T) { + client := newTestClient(t) + + email := fmt.Sprintf("integration-derived-username-%d@example.com", time.Now().UnixNano()) + password := uniquePassword() + + payload := map[string]any{ + "email": email, + "password": password, + "global_name": "Magic Tester", + "date_of_birth": adultDateOfBirth(), + "consent": true, + } + + resp, err := client.postJSON("/auth/register", payload) + if err != nil { + t.Fatalf("failed to call register endpoint: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("register returned %d: %s", resp.StatusCode, readResponseBody(resp)) + } + + var parsed registerResponse + decodeJSONResponse(t, resp, &parsed) + if parsed.Token == "" { + t.Fatalf("expected register response to include a token") + } + + meResp, err := client.getWithAuth("/users/@me", parsed.Token) + if err != nil { + t.Fatalf("failed to fetch current user: %v", err) + } + if meResp.StatusCode != http.StatusOK { + t.Fatalf("/users/@me returned %d: %s", meResp.StatusCode, readResponseBody(meResp)) + } + + var userResp userPrivateResponse + decodeJSONResponse(t, meResp, &userResp) + if userResp.Username != "Magic_Tester" { + t.Fatalf("expected derived username to be %q, got %q", "Magic_Tester", userResp.Username) + } +}