Files
BetiX/app/Models/User.php
Dolo 0280278978
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Initialer Laravel Commit für BetiX
2026-04-04 18:01:50 +02:00

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']);
}
}