Files
SkinbaseNova/app/Support/UsernamePolicy.php
2026-03-12 07:22:38 +01:00

134 lines
3.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class UsernamePolicy
{
public static function min(): int
{
return (int) config('usernames.min', 3);
}
public static function max(): int
{
return (int) config('usernames.max', 20);
}
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_]{3,20}$/');
}
/**
* @return array<int, string>
*/
public static function reserved(): array
{
$pool = [
...(array) config('usernames.reserved', []),
...(array) config('skinbase.reserved_usernames', []),
];
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), $pool)));
}
public static function normalize(string $value): string
{
return strtolower(trim($value));
}
public static function sanitizeLegacy(string $value): string
{
$value = Str::ascii($value);
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9_-]+/', '_', $value) ?? '';
$value = trim($value, '_-');
if ($value === '') {
return 'user';
}
return substr($value, 0, self::max());
}
public static function isReserved(string $username): bool
{
return in_array(self::normalize($username), self::reserved(), true);
}
public static function similarReserved(string $username): ?string
{
$normalized = self::normalize($username);
$reduced = self::reduceForSimilarity($normalized);
$threshold = (int) config('usernames.similarity_threshold', 2);
foreach (self::reserved() as $reserved) {
if (levenshtein($reduced, self::reduceForSimilarity($reserved)) <= $threshold) {
return $reserved;
}
}
return null;
}
public static function hasApprovedOverride(string $username, ?int $userId = null): bool
{
if (! Schema::hasTable('username_approval_requests')) {
return false;
}
$normalized = self::normalize($username);
return DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('status', 'approved')
->when($userId !== null, fn ($q) => $q->where(function ($sub) use ($userId) {
$sub->where('user_id', $userId)->orWhereNull('user_id');
}))
->exists();
}
public static function uniqueCandidate(string $base, ?int $ignoreUserId = null): string
{
$base = self::sanitizeLegacy($base);
if ($base === '' || self::isReserved($base) || self::similarReserved($base) !== null) {
$base = 'user';
}
$max = self::max();
$candidate = substr($base, 0, $max);
$suffix = 1;
while (self::exists($candidate, $ignoreUserId) || self::isReserved($candidate) || self::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
return $candidate;
}
private static function exists(string $username, ?int $ignoreUserId = null): bool
{
$query = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)]);
if ($ignoreUserId !== null) {
$query->where('id', '!=', $ignoreUserId);
}
return $query->exists();
}
private static function reduceForSimilarity(string $value): string
{
return preg_replace('/[0-9_-]+/', '', strtolower($value)) ?? strtolower($value);
}
}