refactor(api): simplify & improve username suggestion logic (#44)
This commit is contained in:
parent
8ca99424ce
commit
1cef2290fe
@ -412,7 +412,7 @@ export const AuthController = (app: HonoApp) => {
|
|||||||
Validator('json', UsernameSuggestionsRequest),
|
Validator('json', UsernameSuggestionsRequest),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const {global_name} = ctx.req.valid('json');
|
const {global_name} = ctx.req.valid('json');
|
||||||
const suggestions = generateUsernameSuggestions(global_name, 5);
|
const suggestions = generateUsernameSuggestions(global_name);
|
||||||
return ctx.json({suggestions});
|
return ctx.json({suggestions});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -45,6 +45,7 @@ import * as AgeUtils from '~/utils/AgeUtils';
|
|||||||
import * as IpUtils from '~/utils/IpUtils';
|
import * as IpUtils from '~/utils/IpUtils';
|
||||||
import {parseAcceptLanguage} from '~/utils/LocaleUtils';
|
import {parseAcceptLanguage} from '~/utils/LocaleUtils';
|
||||||
import {generateRandomUsername} from '~/utils/UsernameGenerator';
|
import {generateRandomUsername} from '~/utils/UsernameGenerator';
|
||||||
|
import {deriveUsernameFromDisplayName} from '~/utils/UsernameSuggestionUtils';
|
||||||
|
|
||||||
const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
|
const MINIMUM_AGE_BY_COUNTRY: Record<string, number> = {
|
||||||
KR: 14,
|
KR: 14,
|
||||||
@ -202,8 +203,32 @@ export class AuthRegistrationService {
|
|||||||
if (emailTaken) throw InputValidationError.create('email', 'Email already in use');
|
if (emailTaken) throw InputValidationError.create('email', 'Email already in use');
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = data.username || generateRandomUsername();
|
let usernameCandidate: string | undefined = data.username ?? undefined;
|
||||||
const discriminator = await this.allocateDiscriminator(username);
|
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 userId = this.generateUserId(emailKey);
|
||||||
|
|
||||||
const acceptLanguage = request.headers.get('accept-language');
|
const acceptLanguage = request.headers.get('accept-language');
|
||||||
|
|||||||
@ -17,64 +17,36 @@
|
|||||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {UsernameType} from '~/Schema';
|
||||||
import {transliterate as tr} from 'transliteration';
|
import {transliterate as tr} from 'transliteration';
|
||||||
import {generateRandomUsername} from '~/utils/UsernameGenerator';
|
|
||||||
|
|
||||||
function sanitizeForFluxerTag(input: string): string {
|
const MAX_USERNAME_LENGTH = 32;
|
||||||
let result = tr(input.trim());
|
|
||||||
|
|
||||||
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, '');
|
let sanitized = tr(trimmed);
|
||||||
|
sanitized = sanitized.replace(/[\s\-.]+/g, '_');
|
||||||
if (!result) {
|
sanitized = sanitized.replace(/[^a-zA-Z0-9_]/g, '');
|
||||||
result = 'user';
|
if (!sanitized) return null;
|
||||||
|
if (sanitized.length > MAX_USERNAME_LENGTH) {
|
||||||
|
sanitized = sanitized.substring(0, MAX_USERNAME_LENGTH);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.length > 32) {
|
const validation = UsernameType.safeParse(sanitized);
|
||||||
result = result.substring(0, 32);
|
if (!validation.success) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.toLowerCase();
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateUsernameSuggestions(globalName: string, count: number = 5): Array<string> {
|
export function deriveUsernameFromDisplayName(globalName: string): string | null {
|
||||||
const suggestions: Array<string> = [];
|
return sanitizeDisplayName(globalName);
|
||||||
|
}
|
||||||
const transliterated = tr(globalName.trim());
|
|
||||||
const hasMeaningfulContent = /[a-zA-Z]/.test(transliterated);
|
export function generateUsernameSuggestions(globalName: string): Array<string> {
|
||||||
|
const candidate = deriveUsernameFromDisplayName(globalName);
|
||||||
if (!hasMeaningfulContent) {
|
return candidate ? [candidate] : [];
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
70
tests/integration/auth_register_display_name_autogen_test.go
Normal file
70
tests/integration/auth_register_display_name_autogen_test.go
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user