134 lines
3.8 KiB
PHP
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);
|
|
}
|
|
}
|