*/ use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. * * @var list */ 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 */ 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 */ 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']); } }