Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->timestampTz('username_manually_set_at')->nullable()->after('username');
$table->timestampTz('username_updated_at')->nullable()->after('username_manually_set_at');
});
}

public function down(): void
{
Schema::table('users', static function (Blueprint $table): void {
$table->dropColumn(['username_manually_set_at', 'username_updated_at']);
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public function execute(User $user, OAuthUserDTO $oauthUser): User
$updates['name'] = $oauthUser->name;
}

$canUpdateUsername = $oauthUser->username !== $user->username
$canUpdateUsername = $user->username_manually_set_at === null
&& $oauthUser->username !== $user->username
&& !User::query()
->where('username', $oauthUser->username)
->where('id', '!=', $user->id)
Expand Down
9 changes: 7 additions & 2 deletions app-modules/identity/src/Auth/Actions/MergeAccountsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,25 @@ private function enrichOldUser(User $source, User $target): void
$updates['name'] = $source->name;
}

$canUpdateUsername = $source->username !== $target->username
$canUpdateUsername = $target->username_manually_set_at === null
&& $source->username !== $target->username
&& !User::query()
->where('username', $source->username)
->where('id', '!=', $target->id)
->exists();

if ($canUpdateUsername) {
$updates['username'] = $source->username;
if ($source->username_manually_set_at !== null) {
$updates['username_manually_set_at'] = $source->username_manually_set_at;
$updates['username_updated_at'] = $source->username_updated_at;
}
}

try {
DB::transaction(fn () => $target->update($updates));
} catch (UniqueConstraintViolationException) {
unset($updates['username']);
unset($updates['username'], $updates['username_manually_set_at'], $updates['username_updated_at']);
$target->update($updates);
}
}
Expand Down
68 changes: 68 additions & 0 deletions app-modules/identity/src/User/Actions/UpdateUsername.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\User\Actions;

use He4rt\Identity\User\Exceptions\UsernameException;
use He4rt\Identity\User\Models\User;
use He4rt\Identity\User\ValueObjects\UsernameValidator;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;

final class UpdateUsername
{
/**
* @throws UsernameException
*/
public function handle(User $user, string $newUsername): User
{
$normalized = UsernameValidator::normalizeAndValidate($newUsername, $user);

if ($normalized === mb_strtolower($user->username)) {
throw UsernameException::sameAsCurrent();
}

if ($user->username_updated_at !== null && !$user->isAdmin()) {
$cooldownDays = (int) config('he4rt.username_cooldown_days', 7);
$availableAt = $user->username_updated_at->copy()->addDays($cooldownDays);

if (now()->lessThan($availableAt)) {
throw UsernameException::cooldownActive($availableAt);
}
}

$isTaken = User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->where('id', '!=', $user->id)
->exists();

if ($isTaken) {
throw UsernameException::alreadyTaken($normalized);
}

try {
DB::transaction(function () use ($user, $normalized): void {
$user->update([
'username' => $normalized,
'username_updated_at' => now(),
'username_manually_set_at' => $user->username_manually_set_at ?? now(),
]);
});
} catch (UniqueConstraintViolationException) {
throw UsernameException::alreadyTaken($normalized);
}

return $user->refresh();
}

/**
* Alias for handle
*
* @throws UsernameException
*/
public function execute(User $user, string $newUsername): User
{
return $this->handle($user, $newUsername);
}
}
99 changes: 99 additions & 0 deletions app-modules/identity/src/User/Exceptions/UsernameException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\User\Exceptions;

use Carbon\CarbonInterface;
use Exception;
use Throwable;

final class UsernameException extends Exception
{
/**
* @param array<string, mixed> $translationParams
*/
public function __construct(
string $message = '',
int $code = 422,
?Throwable $previous = null,
public readonly ?string $translationKey = null,
public readonly array $translationParams = [],
) {
parent::__construct($message, $code, $previous);
}

public static function alreadyTaken(string $username): self
{
return new self(
sprintf('O @%s já está em uso por outro membro.', $username),
422,
translationKey: 'panel-app::profile.validation.username_already_taken',
translationParams: ['username' => $username],
);
}

public static function invalidFormat(string $reason, ?string $reasonKey = null): self
{
return new self(
sprintf('Formato de @ inválido: %s.', $reason),
422,
translationKey: 'panel-app::profile.validation.username_invalid_format',
translationParams: ['reason' => $reason, 'reason_key' => $reasonKey],
);
}

public static function cooldownActive(CarbonInterface $availableAt): self
{
$timezone = (string) config('app.display_timezone', 'America/Sao_Paulo');
$formattedDate = $availableAt->timezone($timezone)->format('d/m/Y H:i');

return new self(
sprintf('Você só poderá alterar seu @ novamente a partir de %s.', $formattedDate),
422,
translationKey: 'panel-app::profile.validation.username_cooldown_active',
translationParams: ['date' => $formattedDate],
);
}

public static function sameAsCurrent(): self
{
return new self(
'O novo @ deve ser diferente do atual.',
422,
translationKey: 'panel-app::profile.validation.username_same_as_current',
);
}

public static function reservedUsername(string $username): self
{
return new self(
sprintf('O @%s está reservado para o sistema e não pode ser utilizado.', $username),
422,
translationKey: 'panel-app::profile.validation.username_reserved',
translationParams: ['username' => $username],
);
}

public function getLocalizedMessage(): string
{
if ($this->translationKey !== null) {
$params = $this->translationParams;
if (isset($params['reason_key']) && is_string($params['reason_key'])) {
$translatedReason = __($params['reason_key']);
if ($translatedReason !== $params['reason_key']) {
$params['reason'] = $translatedReason;
}

unset($params['reason_key']);
}

$translated = __($this->translationKey, $params);
if ($translated !== $this->translationKey) {
return $translated;
}
}

return $this->getMessage();
}
}
13 changes: 13 additions & 0 deletions app-modules/identity/src/User/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
* @property CarbonInterface|null $suspended_until
* @property CarbonInterface|null $banned_at
* @property CarbonInterface|null $first_login_at
* @property CarbonInterface|null $username_manually_set_at
* @property CarbonInterface|null $username_updated_at
* @property string|null $remember_token
* @property CarbonInterface|null $created_at
* @property CarbonInterface|null $updated_at
Expand Down Expand Up @@ -71,6 +73,15 @@ public function isSuperAdmin(): bool
return $this->hasRole(UserRole::SuperAdmin);
}

public function isAdmin(): bool
{
$admins = array_filter(explode(',', (string) config('he4rt.admins', '')));

return $this->isSuperAdmin()
|| in_array($this->username, $admins, strict: true)
|| in_array($this->id, $admins, strict: true);
}

/**
* @return MorphMany<ExternalIdentity, $this>
*/
Expand Down Expand Up @@ -154,6 +165,8 @@ protected function casts(): array
'suspended_until' => 'datetime',
'banned_at' => 'datetime',
'first_login_at' => 'datetime',
'username_manually_set_at' => 'datetime',
'username_updated_at' => 'datetime',
];
}

Expand Down
102 changes: 102 additions & 0 deletions app-modules/identity/src/User/ValueObjects/UsernameValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\User\ValueObjects;

use He4rt\Identity\User\Exceptions\UsernameException;
use He4rt\Identity\User\Models\User;

final class UsernameValidator
{
/**
* @var list<string>
*/
public const array RESERVED_USERNAMES = [
'admin',
'administrator',
'system',
'root',
'he4rt',
'heart',
'he4rtdevs',
'mod',
'moderator',
'staff',
'support',
'help',
'api',
'bot',
'null',
'undefined',
'anonymous',
'everyone',
'here',
Comment on lines +16 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acho que vale também adicionar os nomes em portuguê, como reservado também.

];

/**
* @throws UsernameException
*/
public static function normalizeAndValidate(string $username, ?User $user = null): string
{
$normalized = mb_strtolower(mb_trim($username));
$length = mb_strlen($normalized);

if ($length < 2 || $length > 32) {
throw UsernameException::invalidFormat(
'o tamanho deve ter entre 2 e 32 caracteres',
'panel-app::profile.validation.username_reason_length',
);
}

if (!preg_match('/^[a-z0-9._-]+$/', $normalized)) {
throw UsernameException::invalidFormat(
'apenas letras, números, sublinhado (_), hífen (-) e ponto (.) são permitidos',
'panel-app::profile.validation.username_reason_characters',
);
}

if (!preg_match('/^[a-z0-9]/', $normalized) || !preg_match('/[a-z0-9]$/', $normalized)) {
throw UsernameException::invalidFormat(
'não pode começar ou terminar com caracteres especiais',
'panel-app::profile.validation.username_reason_edges',
);
}

if (preg_match('/[._-]{2,}/', $normalized)) {
throw UsernameException::invalidFormat(
'não pode conter caracteres especiais consecutivos',
'panel-app::profile.validation.username_reason_consecutive',
);
}

if (in_array($normalized, self::RESERVED_USERNAMES, strict: true)) {
throw UsernameException::reservedUsername($normalized);
}

$adminsConfig = (string) config('he4rt.admins', '');
$adminEntries = array_filter(array_map(trim(...), explode(',', $adminsConfig)));
$adminNames = array_map(strtolower(...), $adminEntries);

if (in_array($normalized, $adminNames, strict: true)) {
$isOwnAdminUsername = $user instanceof User && (
mb_strtolower($user->username) === $normalized
|| in_array(mb_strtolower($user->id), $adminNames, strict: true)
);

if (!$isOwnAdminUsername) {
throw UsernameException::reservedUsername($normalized);
}
}

return $normalized;
}

/**
* @throws UsernameException
*/
public static function validate(string $username, ?User $user = null): string
{
return self::normalizeAndValidate($username, $user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,19 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self
expect($result->email)->toBe('existing@example.com')
->and($result->first_login_at)->not->toBeNull();
});

test('does not overwrite username when username_manually_set_at is present', function (): void {
$user = User::factory()->create([
'username' => 'custom-username',
'first_login_at' => null,
'username_manually_set_at' => now()->subDay(),
]);

$action = new EnrichUserOnFirstLogin();
$result = $action->execute(
$user,
makeOAuthUserForEnrich(username: 'oauth-username'),
);

expect($result->username)->toBe('custom-username');
});
Loading