304 lines
8.7 KiB
PHP
304 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
use Illuminate\Notifications\Notifiable;
|
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
|
use App\Casts\EncryptedDecimal;
|
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
|
use App\Notifications\VerifyEmail;
|
|
use App\Notifications\ResetPassword;
|
|
use App\Casts\SafeEncryptedString;
|
|
use Illuminate\Encryption\Encrypter;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class User extends Authenticatable implements MustVerifyEmail
|
|
{
|
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
|
|
|
/**
|
|
* The attributes that are mass assignable.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $fillable = [
|
|
'name',
|
|
'email',
|
|
'email_index', // Blind Index for lookups
|
|
'username',
|
|
'username_index', // Blind Index for lookups
|
|
'avatar_url', // Profile image for chat/header
|
|
'role', // Role badge (e.g., Admin, Streamer, Co)
|
|
'clan_tag', // Clan short tag badge
|
|
// Profile fields
|
|
'first_name',
|
|
'last_name',
|
|
'gender',
|
|
'birthdate',
|
|
'phone',
|
|
'country',
|
|
'address_line1',
|
|
'address_line2',
|
|
'city',
|
|
'state',
|
|
'postal_code',
|
|
'currency',
|
|
'is_adult',
|
|
'password',
|
|
'last_login_at',
|
|
'last_login_ip',
|
|
'last_login_user_agent',
|
|
'balance', // BTX Balance
|
|
'vip_level',
|
|
'vault_balance',
|
|
'vault_balances',
|
|
'preferred_locale',
|
|
// Social Profile Fields
|
|
'is_public',
|
|
'bio',
|
|
'avatar',
|
|
'banner',
|
|
'is_banned', // Added
|
|
'withdraw_cooldown_until',
|
|
'registration_ip',
|
|
];
|
|
|
|
/**
|
|
* The attributes that should be hidden for serialization.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
protected $hidden = [
|
|
'password',
|
|
'two_factor_secret',
|
|
'two_factor_recovery_codes',
|
|
'remember_token',
|
|
'birthdate', // Hide sensitive data
|
|
'phone', // Hide sensitive data
|
|
'email_index',
|
|
'username_index',
|
|
'last_login_ip',
|
|
'last_login_user_agent',
|
|
];
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'email_verified_at' => 'datetime',
|
|
'password' => 'hashed',
|
|
'two_factor_confirmed_at' => 'datetime',
|
|
'email' => SafeEncryptedString::class,
|
|
'is_adult' => 'boolean',
|
|
'last_login_at' => 'datetime',
|
|
'balance' => 'decimal:4',
|
|
'vip_level' => 'integer',
|
|
'vault_balance' => 'decimal:4', // Store and handle as plaintext decimal now
|
|
'vault_balances' => 'array', // JSON map: {"BTC":"0","ETH":"0","SOL":"0"}
|
|
'is_public' => 'boolean',
|
|
'is_banned' => 'boolean', // Added
|
|
// Vault PIN-related
|
|
'vault_pin_set_at' => 'datetime',
|
|
'vault_pin_locked_until' => 'datetime',
|
|
'vault_pin_attempts' => 'integer',
|
|
'withdraw_cooldown_until' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Automatically hash email and username for blind indexing on save.
|
|
*/
|
|
protected static function booted(): void
|
|
{
|
|
static::saving(function (User $user) {
|
|
if ($user->isDirty('email')) {
|
|
// Store email hash in lowercase for case-insensitive lookup
|
|
$user->email_index = hash('sha256', strtolower($user->email));
|
|
}
|
|
if ($user->isDirty('username')) {
|
|
// Store username hash in lowercase for case-insensitive lookup
|
|
$user->username_index = hash('sha256', strtolower($user->username));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the user's stats.
|
|
*/
|
|
public function stats()
|
|
{
|
|
return $this->hasOne(UserStats::class);
|
|
}
|
|
|
|
/**
|
|
* Get the user's wallets.
|
|
*/
|
|
public function wallets()
|
|
{
|
|
return $this->hasMany(Wallet::class);
|
|
}
|
|
|
|
/**
|
|
* Get the user's guild membership.
|
|
*/
|
|
public function guildMember()
|
|
{
|
|
return $this->hasOne(GuildMember::class);
|
|
}
|
|
|
|
/**
|
|
* Get all restrictions for the user.
|
|
*/
|
|
public function restrictions()
|
|
{
|
|
return $this->hasMany(UserRestriction::class);
|
|
}
|
|
|
|
/**
|
|
* Check if the user has an active account ban.
|
|
*
|
|
* @return \Illuminate\Database\Eloquent\Casts\Attribute
|
|
*/
|
|
protected function isBanned(): \Illuminate\Database\Eloquent\Casts\Attribute
|
|
{
|
|
return \Illuminate\Database\Eloquent\Casts\Attribute::make(
|
|
get: fn () => $this->restrictions()->where('type', 'account_ban')->where('active', true)->exists(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the email address that should be used for password reset.
|
|
*
|
|
* @return string
|
|
*/
|
|
public function getEmailForPasswordReset()
|
|
{
|
|
return $this->email; // Use the actual email
|
|
}
|
|
|
|
/**
|
|
* Send the email verification notification.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function sendEmailVerificationNotification()
|
|
{
|
|
$this->notify(new VerifyEmail);
|
|
}
|
|
|
|
/**
|
|
* Send the password reset notification.
|
|
*
|
|
* @param string $token
|
|
* @return void
|
|
*/
|
|
public function sendPasswordResetNotification($token)
|
|
{
|
|
$this->notify(new ResetPassword($token));
|
|
}
|
|
|
|
/**
|
|
* Ensure MailChannel gets a plaintext, RFC-compliant email address.
|
|
* If the stored value is still encrypted/malformed, try to decrypt
|
|
* using configured keys. If still invalid, log and return null to skip send.
|
|
*
|
|
* Returning null will prevent the Mail channel from sending and avoids
|
|
* Symfony RfcComplianceException while we fix upstream data/keys.
|
|
*
|
|
* @return string|array|null
|
|
*/
|
|
public function routeNotificationForMail(): string|array|null
|
|
{
|
|
// 1) Try the casted value first (should be plaintext if keys are aligned)
|
|
$candidates = [];
|
|
$casted = $this->email;
|
|
if (is_string($casted)) {
|
|
$candidates[] = $casted;
|
|
}
|
|
|
|
// 2) Try decrypting the raw DB value explicitly if it looks encrypted
|
|
$raw = (string) $this->getRawOriginal('email');
|
|
if ($raw !== '') {
|
|
$candidates[] = $raw;
|
|
$decrypted = $this->tryDecrypt($raw);
|
|
if ($decrypted !== null) {
|
|
$candidates[] = $decrypted;
|
|
}
|
|
}
|
|
|
|
// 3) As last resort, try decrypting the casted value too (in case a layer re-wrapped it)
|
|
if (is_string($casted)) {
|
|
$dec2 = $this->tryDecrypt($casted);
|
|
if ($dec2 !== null) {
|
|
$candidates[] = $dec2;
|
|
}
|
|
}
|
|
|
|
foreach ($candidates as $value) {
|
|
if (is_string($value) && $this->isValidEmail($value)) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
// No valid address could be obtained. Log once per user session/context.
|
|
Log::warning('Unable to derive plaintext email for mail routing; skipping send to avoid RFC error', [
|
|
'user_id' => $this->id,
|
|
'env' => app()->environment(),
|
|
]);
|
|
|
|
// In local/dev, you may prefer to route to MAIL_FROM_ADDRESS to unblock flows:
|
|
if (app()->environment(['local', 'development'])) {
|
|
$fallback = (string) config('mail.from.address');
|
|
if ($this->isValidEmail($fallback)) {
|
|
return $fallback;
|
|
}
|
|
}
|
|
|
|
// Returning null prevents MailChannel from attempting a send
|
|
return null;
|
|
}
|
|
|
|
private function tryDecrypt(?string $value): ?string
|
|
{
|
|
if (!is_string($value) || $value === '') {
|
|
return null;
|
|
}
|
|
if (!$this->looksEncrypted($value)) {
|
|
return null;
|
|
}
|
|
try {
|
|
return Crypt::decryptString($value);
|
|
} catch (\Throwable $e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function isValidEmail(string $email): bool
|
|
{
|
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
|
}
|
|
|
|
private function looksEncrypted(string $value): bool
|
|
{
|
|
$decoded = base64_decode($value, true);
|
|
if ($decoded === false) {
|
|
return false;
|
|
}
|
|
$json = json_decode($decoded, true);
|
|
if (!is_array($json)) {
|
|
return false;
|
|
}
|
|
return isset($json['iv'], $json['value'], $json['mac']);
|
|
}
|
|
}
|