NNTmux /
newznab-tmux
| 1 | <?php |
||
| 2 | |||
| 3 | declare(strict_types=1); |
||
| 4 | |||
| 5 | namespace App\Models; |
||
| 6 | |||
| 7 | use App\Enums\QueueType; |
||
| 8 | use App\Enums\SignupError; |
||
| 9 | use App\Enums\UserRole; |
||
| 10 | use App\Jobs\SendAccountExpiredEmail; |
||
| 11 | use App\Jobs\SendAccountWillExpireEmail; |
||
| 12 | use App\Rules\ValidEmailDomain; |
||
| 13 | use App\Services\InvitationService; |
||
| 14 | use Carbon\CarbonImmutable; |
||
| 15 | use Illuminate\Database\Eloquent\Builder; |
||
| 16 | use Illuminate\Database\Eloquent\Casts\Attribute; |
||
| 17 | use Illuminate\Database\Eloquent\Collection; |
||
| 18 | use Illuminate\Database\Eloquent\Factories\HasFactory; |
||
| 19 | use Illuminate\Database\Eloquent\Relations\BelongsTo; |
||
| 20 | use Illuminate\Database\Eloquent\Relations\HasMany; |
||
| 21 | use Illuminate\Database\Eloquent\Relations\HasOne; |
||
| 22 | use Illuminate\Database\Eloquent\SoftDeletes; |
||
| 23 | use Illuminate\Foundation\Auth\User as Authenticatable; |
||
| 24 | use Illuminate\Http\Request; |
||
| 25 | use Illuminate\Notifications\Notifiable; |
||
| 26 | use Illuminate\Support\Arr; |
||
| 27 | use Illuminate\Support\Carbon; |
||
| 28 | use Illuminate\Support\Facades\Hash; |
||
| 29 | use Illuminate\Support\Facades\Log; |
||
| 30 | use Illuminate\Support\Facades\Password; |
||
| 31 | use Illuminate\Support\Facades\Validator; |
||
| 32 | use Illuminate\Support\Str; |
||
| 33 | use Jrean\UserVerification\Traits\UserVerification; |
||
| 34 | use Spatie\Permission\Models\Role; |
||
| 35 | use Spatie\Permission\Traits\HasRoles; |
||
| 36 | |||
| 37 | /** |
||
| 38 | * @property int $id |
||
| 39 | * @property string $username |
||
| 40 | * @property string|null $firstname |
||
| 41 | * @property string|null $lastname |
||
| 42 | * @property string $email |
||
| 43 | * @property string $password |
||
| 44 | * @property int $roles_id |
||
| 45 | * @property string|null $host |
||
| 46 | * @property int $grabs |
||
| 47 | * @property string $api_token |
||
| 48 | * @property Carbon|null $created_at |
||
| 49 | * @property Carbon|null $updated_at |
||
| 50 | * @property string|null $resetguid |
||
| 51 | * @property Carbon|null $lastlogin |
||
| 52 | * @property Carbon|null $apiaccess |
||
| 53 | * @property int $invites |
||
| 54 | * @property int|null $invitedby |
||
| 55 | * @property bool $movieview |
||
| 56 | * @property bool $xxxview |
||
| 57 | * @property bool $musicview |
||
| 58 | * @property bool $consoleview |
||
| 59 | * @property bool $bookview |
||
| 60 | * @property bool $gameview |
||
| 61 | * @property string|null $saburl |
||
| 62 | * @property string|null $sabapikey |
||
| 63 | * @property bool|null $sabapikeytype |
||
| 64 | * @property bool|null $sabpriority |
||
| 65 | * @property string|null $nzbgeturl |
||
| 66 | * @property string|null $nzbgetusername |
||
| 67 | * @property string|null $nzbgetpassword |
||
| 68 | * @property string|null $nzbvortex_api_key |
||
| 69 | * @property string|null $nzbvortex_server_url |
||
| 70 | * @property string $notes |
||
| 71 | * @property string|null $cp_url |
||
| 72 | * @property string|null $cp_api |
||
| 73 | * @property string|null $style |
||
| 74 | * @property Carbon|null $rolechangedate |
||
| 75 | * @property Carbon|null $pending_role_start_date |
||
| 76 | * @property int|null $pending_roles_id |
||
| 77 | * @property string|null $remember_token |
||
| 78 | * @property int $rate_limit |
||
| 79 | * @property Carbon|null $email_verified_at |
||
| 80 | * @property bool $verified |
||
| 81 | * @property string|null $verification_token |
||
| 82 | * @property string|null $timezone |
||
| 83 | * @property bool $can_post |
||
| 84 | * @property-read Collection<int, ReleaseComment> $comments |
||
| 85 | * @property-read Collection<int, UserDownload> $downloads |
||
| 86 | * @property-read Collection<int, DnzbFailure> $failedReleases |
||
| 87 | * @property-read Collection<int, Invitation> $invitations |
||
| 88 | * @property-read Collection<int, UsersRelease> $releases |
||
| 89 | * @property-read Collection<int, UserRequest> $requests |
||
| 90 | * @property-read Collection<int, UserSerie> $series |
||
| 91 | * @property-read Collection<int, RolePromotionStat> $promotionStats |
||
| 92 | * @property-read Collection<int, UserRoleHistory> $roleHistory |
||
| 93 | * @property-read Role|null $role |
||
| 94 | * @property-read PasswordSecurity|null $passwordSecurity |
||
| 95 | * |
||
| 96 | * @method static Builder|User whereUsername(string $value) |
||
| 97 | * @method static Builder|User whereEmail(string $value) |
||
| 98 | * @method static Builder|User whereApiToken(string $value) |
||
| 99 | * @method static Builder|User whereResetguid(string $value) |
||
| 100 | * @method static Builder|User whereVerified(int $value) |
||
| 101 | * @method static Builder|User active() |
||
| 102 | * @method static Builder|User verified() |
||
| 103 | * @method static Builder|User withRole(int|string $role) |
||
| 104 | * @method static Builder|User expiringSoon(int $days = 7) |
||
| 105 | * @method static Builder|User expired() |
||
| 106 | */ |
||
| 107 | final class User extends Authenticatable |
||
| 108 | { |
||
| 109 | use HasFactory; |
||
| 110 | use HasRoles; |
||
| 111 | use Notifiable; |
||
| 112 | use SoftDeletes; |
||
| 113 | use UserVerification; |
||
| 114 | |||
| 115 | /** |
||
| 116 | * @var list<string> |
||
| 117 | */ |
||
| 118 | protected $hidden = [ |
||
| 119 | 'remember_token', |
||
| 120 | 'password', |
||
| 121 | ]; |
||
| 122 | |||
| 123 | /** |
||
| 124 | * @var list<string> |
||
| 125 | */ |
||
| 126 | protected $guarded = []; |
||
| 127 | |||
| 128 | /** |
||
| 129 | * Roles excluded from promotions. |
||
| 130 | * |
||
| 131 | * @var list<string> |
||
| 132 | */ |
||
| 133 | private const PROMOTION_EXCLUDED_ROLES = [ |
||
| 134 | 'User', |
||
| 135 | 'Admin', |
||
| 136 | 'Moderator', |
||
| 137 | 'Disabled', |
||
| 138 | 'Friend', |
||
| 139 | ]; |
||
| 140 | |||
| 141 | /** |
||
| 142 | * Days in a year for subscription calculations. |
||
| 143 | */ |
||
| 144 | private const DAYS_PER_YEAR = 365; |
||
| 145 | |||
| 146 | /** |
||
| 147 | * Get the attributes that should be cast. |
||
| 148 | * |
||
| 149 | * @return array<string, string> |
||
| 150 | */ |
||
| 151 | protected function casts(): array |
||
| 152 | { |
||
| 153 | return [ |
||
| 154 | 'email_verified_at' => 'datetime', |
||
| 155 | 'lastlogin' => 'datetime', |
||
| 156 | 'apiaccess' => 'datetime', |
||
| 157 | 'rolechangedate' => 'datetime', |
||
| 158 | 'pending_role_start_date' => 'datetime', |
||
| 159 | 'created_at' => 'datetime', |
||
| 160 | 'updated_at' => 'datetime', |
||
| 161 | 'password' => 'hashed', |
||
| 162 | 'movieview' => 'boolean', |
||
| 163 | 'xxxview' => 'boolean', |
||
| 164 | 'musicview' => 'boolean', |
||
| 165 | 'consoleview' => 'boolean', |
||
| 166 | 'bookview' => 'boolean', |
||
| 167 | 'gameview' => 'boolean', |
||
| 168 | 'sabapikeytype' => 'boolean', |
||
| 169 | 'sabpriority' => 'boolean', |
||
| 170 | 'verified' => 'boolean', |
||
| 171 | 'can_post' => 'boolean', |
||
| 172 | 'grabs' => 'integer', |
||
| 173 | 'invites' => 'integer', |
||
| 174 | 'rate_limit' => 'integer', |
||
| 175 | 'roles_id' => 'integer', |
||
| 176 | 'pending_roles_id' => 'integer', |
||
| 177 | 'invitedby' => 'integer', |
||
| 178 | ]; |
||
| 179 | } |
||
| 180 | |||
| 181 | protected function getDefaultGuardName(): string |
||
| 182 | { |
||
| 183 | return 'web'; |
||
| 184 | } |
||
| 185 | |||
| 186 | // ===== Relationships ===== |
||
| 187 | |||
| 188 | public function role(): BelongsTo |
||
| 189 | { |
||
| 190 | return $this->belongsTo(Role::class, 'roles_id'); |
||
| 191 | } |
||
| 192 | |||
| 193 | public function requests(): HasMany |
||
| 194 | { |
||
| 195 | return $this->hasMany(UserRequest::class, 'users_id'); |
||
| 196 | } |
||
| 197 | |||
| 198 | public function downloads(): HasMany |
||
| 199 | { |
||
| 200 | return $this->hasMany(UserDownload::class, 'users_id'); |
||
| 201 | } |
||
| 202 | |||
| 203 | public function releases(): HasMany |
||
| 204 | { |
||
| 205 | return $this->hasMany(UsersRelease::class, 'users_id'); |
||
| 206 | } |
||
| 207 | |||
| 208 | public function series(): HasMany |
||
| 209 | { |
||
| 210 | return $this->hasMany(UserSerie::class, 'users_id'); |
||
| 211 | } |
||
| 212 | |||
| 213 | public function invitations(): HasMany |
||
| 214 | { |
||
| 215 | return $this->hasMany(Invitation::class, 'invited_by'); |
||
| 216 | } |
||
| 217 | |||
| 218 | public function failedReleases(): HasMany |
||
| 219 | { |
||
| 220 | return $this->hasMany(DnzbFailure::class, 'users_id'); |
||
| 221 | } |
||
| 222 | |||
| 223 | public function comments(): HasMany |
||
| 224 | { |
||
| 225 | return $this->hasMany(ReleaseComment::class, 'users_id'); |
||
| 226 | } |
||
| 227 | |||
| 228 | public function promotionStats(): HasMany |
||
| 229 | { |
||
| 230 | return $this->hasMany(RolePromotionStat::class); |
||
| 231 | } |
||
| 232 | |||
| 233 | public function roleHistory(): HasMany |
||
| 234 | { |
||
| 235 | return $this->hasMany(UserRoleHistory::class); |
||
| 236 | } |
||
| 237 | |||
| 238 | public function passwordSecurity(): HasOne |
||
| 239 | { |
||
| 240 | return $this->hasOne(PasswordSecurity::class); |
||
| 241 | } |
||
| 242 | |||
| 243 | // ===== Backward Compatibility Aliases ===== |
||
| 244 | |||
| 245 | public function request(): HasMany |
||
| 246 | { |
||
| 247 | return $this->requests(); |
||
| 248 | } |
||
| 249 | |||
| 250 | public function download(): HasMany |
||
| 251 | { |
||
| 252 | return $this->downloads(); |
||
| 253 | } |
||
| 254 | |||
| 255 | public function release(): HasMany |
||
| 256 | { |
||
| 257 | return $this->releases(); |
||
| 258 | } |
||
| 259 | |||
| 260 | public function invitation(): HasMany |
||
| 261 | { |
||
| 262 | return $this->invitations(); |
||
| 263 | } |
||
| 264 | |||
| 265 | public function failedRelease(): HasMany |
||
| 266 | { |
||
| 267 | return $this->failedReleases(); |
||
| 268 | } |
||
| 269 | |||
| 270 | public function comment(): HasMany |
||
| 271 | { |
||
| 272 | return $this->comments(); |
||
| 273 | } |
||
| 274 | |||
| 275 | // ===== Query Scopes ===== |
||
| 276 | |||
| 277 | /** |
||
| 278 | * Scope to get active (non-disabled) users. |
||
| 279 | */ |
||
| 280 | public function scopeActive(Builder $query): Builder |
||
| 281 | { |
||
| 282 | return $query->where('roles_id', '!=', UserRole::DISABLED->value); |
||
| 283 | } |
||
| 284 | |||
| 285 | /** |
||
| 286 | * Scope to get verified users. |
||
| 287 | */ |
||
| 288 | public function scopeVerified(Builder $query): Builder |
||
| 289 | { |
||
| 290 | return $query->where('verified', true); |
||
| 291 | } |
||
| 292 | |||
| 293 | /** |
||
| 294 | * Scope to filter users by role. |
||
| 295 | */ |
||
| 296 | public function scopeWithRole(Builder $query, int|string $role): Builder |
||
| 297 | { |
||
| 298 | if (is_numeric($role)) { |
||
| 299 | return $query->where('roles_id', (int) $role); |
||
| 300 | } |
||
| 301 | |||
| 302 | return $query->whereHas('role', fn (Builder $q) => $q->where('name', $role)); |
||
| 303 | } |
||
| 304 | |||
| 305 | /** |
||
| 306 | * Scope to get users with roles expiring within specified days. |
||
| 307 | */ |
||
| 308 | public function scopeExpiringSoon(Builder $query, int $days = 7): Builder |
||
| 309 | { |
||
| 310 | return $query->whereNotNull('rolechangedate') |
||
| 311 | ->whereBetween('rolechangedate', [now(), now()->addDays($days)]); |
||
| 312 | } |
||
| 313 | |||
| 314 | /** |
||
| 315 | * Scope to get users with expired roles. |
||
| 316 | */ |
||
| 317 | public function scopeExpired(Builder $query): Builder |
||
| 318 | { |
||
| 319 | return $query->whereNotNull('rolechangedate') |
||
| 320 | ->where('rolechangedate', '<', now()); |
||
| 321 | } |
||
| 322 | |||
| 323 | /** |
||
| 324 | * Scope to exclude sharing email. |
||
| 325 | */ |
||
| 326 | public function scopeExcludeSharing(Builder $query): Builder |
||
| 327 | { |
||
| 328 | return $query->where('email', '!=', '[email protected]'); |
||
| 329 | } |
||
| 330 | |||
| 331 | // ===== Attribute Accessors ===== |
||
| 332 | |||
| 333 | /** |
||
| 334 | * Get the user's full name. |
||
| 335 | */ |
||
| 336 | protected function fullName(): Attribute |
||
| 337 | { |
||
| 338 | return Attribute::make( |
||
| 339 | get: fn (): string => trim("{$this->firstname} {$this->lastname}") ?: $this->username, |
||
| 340 | ); |
||
| 341 | } |
||
| 342 | |||
| 343 | /** |
||
| 344 | * Get the user's timezone or default. |
||
| 345 | */ |
||
| 346 | protected function effectiveTimezone(): Attribute |
||
| 347 | { |
||
| 348 | return Attribute::make( |
||
| 349 | get: fn (): string => $this->timezone ?? 'UTC', |
||
| 350 | ); |
||
| 351 | } |
||
| 352 | |||
| 353 | /** |
||
| 354 | * Check if user has admin privileges. |
||
| 355 | */ |
||
| 356 | protected function isAdmin(): Attribute |
||
| 357 | { |
||
| 358 | return Attribute::make( |
||
| 359 | get: fn (): bool => $this->roles_id === UserRole::ADMIN->value, |
||
| 360 | ); |
||
| 361 | } |
||
| 362 | |||
| 363 | /** |
||
| 364 | * Check if user is disabled. |
||
| 365 | */ |
||
| 366 | protected function isDisabled(): Attribute |
||
| 367 | { |
||
| 368 | return Attribute::make( |
||
| 369 | get: fn (): bool => $this->roles_id === UserRole::DISABLED->value, |
||
| 370 | ); |
||
| 371 | } |
||
| 372 | |||
| 373 | /** |
||
| 374 | * Check if role is expired. |
||
| 375 | */ |
||
| 376 | protected function isRoleExpired(): Attribute |
||
| 377 | { |
||
| 378 | return Attribute::make( |
||
| 379 | get: fn (): bool => $this->rolechangedate?->isPast() ?? false, |
||
| 380 | ); |
||
| 381 | } |
||
| 382 | |||
| 383 | /** |
||
| 384 | * Get days until role expires. |
||
| 385 | */ |
||
| 386 | protected function daysUntilExpiry(): Attribute |
||
| 387 | { |
||
| 388 | return Attribute::make( |
||
| 389 | get: fn (): ?int => $this->rolechangedate |
||
| 390 | ? (int) now()->diffInDays($this->rolechangedate, false) |
||
| 391 | : null, |
||
| 392 | ); |
||
| 393 | } |
||
| 394 | |||
| 395 | // ===== Pending Role Methods ===== |
||
| 396 | |||
| 397 | /** |
||
| 398 | * Check if user has a pending stacked role. |
||
| 399 | */ |
||
| 400 | public function hasPendingRole(): bool |
||
| 401 | { |
||
| 402 | return $this->pending_roles_id !== null && $this->pending_role_start_date !== null; |
||
| 403 | } |
||
| 404 | |||
| 405 | /** |
||
| 406 | * Get the pending role. |
||
| 407 | */ |
||
| 408 | public function getPendingRole(): ?Role |
||
| 409 | { |
||
| 410 | if (! $this->hasPendingRole()) { |
||
| 411 | return null; |
||
| 412 | } |
||
| 413 | |||
| 414 | return Role::find($this->pending_roles_id); |
||
| 415 | } |
||
| 416 | |||
| 417 | /** |
||
| 418 | * Cancel a pending stacked role. |
||
| 419 | */ |
||
| 420 | public function cancelPendingRole(): bool |
||
| 421 | { |
||
| 422 | if (! $this->hasPendingRole()) { |
||
| 423 | return false; |
||
| 424 | } |
||
| 425 | |||
| 426 | return $this->update([ |
||
| 427 | 'pending_roles_id' => null, |
||
| 428 | 'pending_role_start_date' => null, |
||
| 429 | ]); |
||
| 430 | } |
||
| 431 | |||
| 432 | /** |
||
| 433 | * Get all pending stacked role changes for this user. |
||
| 434 | * Returns an array of stacked role changes that haven't been activated yet (effective_date is in the future). |
||
| 435 | * |
||
| 436 | * @return \Illuminate\Support\Collection<int, array{ |
||
| 437 | * role: Role|null, |
||
| 438 | * role_name: string, |
||
| 439 | * start_date: Carbon, |
||
| 440 | * end_date: Carbon, |
||
| 441 | * is_current_pending: bool |
||
| 442 | * }> |
||
| 443 | */ |
||
| 444 | public function getAllPendingStackedRoles(): \Illuminate\Support\Collection |
||
| 445 | { |
||
| 446 | // Get all stacked role changes from history where effective_date is in the future |
||
| 447 | $stackedHistory = UserRoleHistory::where('user_id', $this->id) |
||
| 448 | ->where('is_stacked', true) |
||
| 449 | ->where('effective_date', '>', now()) |
||
| 450 | ->orderBy('effective_date', 'asc') |
||
| 451 | ->get(); |
||
| 452 | |||
| 453 | return $stackedHistory->map(function ($history) { |
||
| 454 | $role = Role::find($history->new_role_id); |
||
| 455 | return [ |
||
| 456 | 'role' => $role, |
||
| 457 | 'role_name' => $role?->name ?? 'Unknown Role', |
||
| 458 | 'start_date' => $history->effective_date, |
||
| 459 | 'end_date' => $history->new_expiry_date, |
||
| 460 | 'is_current_pending' => $this->pending_roles_id === $history->new_role_id |
||
| 461 | && $this->pending_role_start_date?->equalTo($history->effective_date), |
||
| 462 | ]; |
||
| 463 | }); |
||
| 464 | } |
||
| 465 | |||
| 466 | /** |
||
| 467 | * Get comprehensive role expiry information. |
||
| 468 | * |
||
| 469 | * @return array{ |
||
| 470 | * current_role: Role|null, |
||
| 471 | * current_expiry: Carbon|null, |
||
| 472 | * has_expiry: bool, |
||
| 473 | * is_expired: bool, |
||
| 474 | * days_until_expiry: int|null, |
||
| 475 | * pending_role: Role|null, |
||
| 476 | * pending_start: Carbon|null, |
||
| 477 | * has_pending_role: bool |
||
| 478 | * } |
||
| 479 | */ |
||
| 480 | public function getRoleExpiryInfo(): array |
||
| 481 | { |
||
| 482 | return [ |
||
| 483 | 'current_role' => $this->role, |
||
| 484 | 'current_expiry' => $this->rolechangedate, |
||
| 485 | 'has_expiry' => $this->rolechangedate !== null, |
||
| 486 | 'is_expired' => $this->is_role_expired, |
||
| 487 | 'days_until_expiry' => $this->days_until_expiry, |
||
| 488 | 'pending_role' => $this->getPendingRole(), |
||
| 489 | 'pending_start' => $this->pending_role_start_date, |
||
| 490 | 'has_pending_role' => $this->hasPendingRole(), |
||
| 491 | ]; |
||
| 492 | } |
||
| 493 | |||
| 494 | // ===== Static Query Methods ===== |
||
| 495 | |||
| 496 | /** |
||
| 497 | * Get count of users matching filters. |
||
| 498 | */ |
||
| 499 | public static function getCount( |
||
| 500 | ?string $role = null, |
||
| 501 | ?string $username = '', |
||
| 502 | ?string $host = '', |
||
| 503 | ?string $email = '', |
||
| 504 | ?string $createdFrom = '', |
||
| 505 | ?string $createdTo = '', |
||
| 506 | ): int { |
||
| 507 | return static::query() |
||
| 508 | ->withTrashed() |
||
| 509 | ->excludeSharing() |
||
| 510 | ->when($role, fn (Builder $q) => $q->where('roles_id', $role)) |
||
| 511 | ->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%")) |
||
| 512 | ->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%")) |
||
| 513 | ->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%")) |
||
| 514 | ->when($createdFrom, fn (Builder $q) => $q->where('created_at', '>=', "{$createdFrom} 00:00:00")) |
||
| 515 | ->when($createdTo, fn (Builder $q) => $q->where('created_at', '<=', "{$createdTo} 23:59:59")) |
||
| 516 | ->count(); |
||
| 517 | } |
||
| 518 | |||
| 519 | /** |
||
| 520 | * Find user by username. |
||
| 521 | */ |
||
| 522 | public static function findByUsername(string $username): ?static |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 523 | { |
||
| 524 | return static::whereUsername($username)->first(); |
||
| 525 | } |
||
| 526 | |||
| 527 | /** |
||
| 528 | * Find user by email. |
||
| 529 | */ |
||
| 530 | public static function findByEmail(string $email): ?static |
||
| 531 | { |
||
| 532 | return static::whereEmail($email)->first(); |
||
| 533 | } |
||
| 534 | |||
| 535 | /** |
||
| 536 | * Find user by RSS token. |
||
| 537 | */ |
||
| 538 | public static function findByRssToken(string $token): ?static |
||
| 539 | { |
||
| 540 | return static::whereApiToken($token)->first(); |
||
| 541 | } |
||
| 542 | |||
| 543 | /** |
||
| 544 | * Find user by password reset GUID. |
||
| 545 | */ |
||
| 546 | public static function findByResetGuid(string $guid): ?static |
||
| 547 | { |
||
| 548 | return static::whereResetguid($guid)->first(); |
||
| 549 | } |
||
| 550 | |||
| 551 | // ===== Backward Compatibility Aliases ===== |
||
| 552 | |||
| 553 | /** |
||
| 554 | * @deprecated Use findByUsername() instead |
||
| 555 | */ |
||
| 556 | public static function getByUsername(string $userName): ?static |
||
| 557 | { |
||
| 558 | return static::findByUsername($userName); |
||
| 559 | } |
||
| 560 | |||
| 561 | /** |
||
| 562 | * @deprecated Use findByEmail() instead |
||
| 563 | */ |
||
| 564 | public static function getByEmail(string $email): ?static |
||
| 565 | { |
||
| 566 | return static::findByEmail($email); |
||
| 567 | } |
||
| 568 | |||
| 569 | /** |
||
| 570 | * @deprecated Use findByRssToken() instead |
||
| 571 | */ |
||
| 572 | public static function getByRssToken(string $rssToken): ?static |
||
| 573 | { |
||
| 574 | return static::findByRssToken($rssToken); |
||
| 575 | } |
||
| 576 | |||
| 577 | /** |
||
| 578 | * @deprecated Use findByResetGuid() instead |
||
| 579 | */ |
||
| 580 | public static function getByPassResetGuid(string $guid): ?static |
||
| 581 | { |
||
| 582 | return static::findByResetGuid($guid); |
||
| 583 | } |
||
| 584 | |||
| 585 | // ===== User Management Methods ===== |
||
| 586 | |||
| 587 | /** |
||
| 588 | * Delete user by ID. |
||
| 589 | * |
||
| 590 | * @throws \Exception |
||
| 591 | */ |
||
| 592 | public static function deleteUser(int $id): void |
||
| 593 | { |
||
| 594 | static::findOrFail($id)->delete(); |
||
| 595 | } |
||
| 596 | |||
| 597 | /** |
||
| 598 | * Update user details. |
||
| 599 | */ |
||
| 600 | public static function updateUser( |
||
| 601 | int $id, |
||
| 602 | string $userName, |
||
| 603 | ?string $email, |
||
| 604 | int $grabs, |
||
| 605 | int $role, |
||
| 606 | ?string $notes, |
||
| 607 | int $invites, |
||
| 608 | int $movieview, |
||
| 609 | int $musicview, |
||
| 610 | int $gameview, |
||
| 611 | int $xxxview, |
||
| 612 | int $consoleview, |
||
| 613 | int $bookview, |
||
| 614 | string $style = 'None', |
||
| 615 | ): int { |
||
| 616 | $user = static::findOrFail($id); |
||
| 617 | $roleModel = Role::find($role); |
||
| 618 | |||
| 619 | $user->update([ |
||
| 620 | 'username' => trim($userName), |
||
| 621 | 'grabs' => $grabs, |
||
| 622 | 'roles_id' => $role, |
||
| 623 | 'notes' => substr($notes ?? '', 0, 255), |
||
| 624 | 'invites' => $invites, |
||
| 625 | 'movieview' => $movieview, |
||
| 626 | 'musicview' => $musicview, |
||
| 627 | 'gameview' => $gameview, |
||
| 628 | 'xxxview' => $xxxview, |
||
| 629 | 'consoleview' => $consoleview, |
||
| 630 | 'bookview' => $bookview, |
||
| 631 | 'style' => $style, |
||
| 632 | 'rate_limit' => $roleModel?->rate_limit ?? 60, |
||
| 633 | ...($email ? ['email' => trim($email)] : []), |
||
| 634 | ]); |
||
| 635 | |||
| 636 | if ($roleModel) { |
||
| 637 | $user->syncRoles([$roleModel->name]); |
||
| 638 | } |
||
| 639 | |||
| 640 | return SignupError::SUCCESS->value; |
||
| 641 | } |
||
| 642 | |||
| 643 | /** |
||
| 644 | * Update user role with optional stacking and promotions. |
||
| 645 | */ |
||
| 646 | public static function updateUserRole( |
||
| 647 | int $uid, |
||
| 648 | int|string $role, |
||
| 649 | bool $applyPromotions = true, |
||
| 650 | bool $stackRole = true, |
||
| 651 | ?int $changedBy = null, |
||
| 652 | ?string $originalExpiryBeforeEdits = null, |
||
| 653 | bool $preserveCurrentExpiry = false, |
||
| 654 | ?int $addYears = null, |
||
| 655 | ): bool { |
||
| 656 | Log::info('updateUserRole called', [ |
||
| 657 | 'uid' => $uid, |
||
| 658 | 'role' => $role, |
||
| 659 | 'applyPromotions' => $applyPromotions, |
||
| 660 | 'stackRole' => $stackRole, |
||
| 661 | 'changedBy' => $changedBy, |
||
| 662 | 'originalExpiryBeforeEdits' => $originalExpiryBeforeEdits, |
||
| 663 | 'preserveCurrentExpiry' => $preserveCurrentExpiry, |
||
| 664 | 'addYears' => $addYears, |
||
| 665 | ]); |
||
| 666 | |||
| 667 | $roleModel = is_numeric($role) |
||
| 668 | ? Role::find((int) $role) |
||
| 669 | : Role::where('name', $role)->first(); |
||
| 670 | |||
| 671 | if (! $roleModel) { |
||
| 672 | Log::error('Role not found', ['role' => $role]); |
||
| 673 | |||
| 674 | return false; |
||
| 675 | } |
||
| 676 | |||
| 677 | $user = static::find($uid); |
||
| 678 | if (! $user) { |
||
| 679 | Log::error('User not found', ['uid' => $uid]); |
||
| 680 | |||
| 681 | return false; |
||
| 682 | } |
||
| 683 | |||
| 684 | $currentRoleId = $user->roles_id; |
||
| 685 | $oldExpiryDate = $originalExpiryBeforeEdits |
||
| 686 | ? Carbon::parse($originalExpiryBeforeEdits) |
||
| 687 | : $user->rolechangedate; |
||
| 688 | $currentExpiryDate = $user->rolechangedate; |
||
| 689 | |||
| 690 | Log::info('updateUserRole - expiry date values', [ |
||
| 691 | 'originalExpiryBeforeEdits_raw' => $originalExpiryBeforeEdits, |
||
| 692 | 'user_rolechangedate_raw' => $user->rolechangedate, |
||
| 693 | 'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(), |
||
| 694 | 'currentExpiryDate' => $currentExpiryDate?->toDateTimeString(), |
||
| 695 | 'currentRoleId' => $currentRoleId, |
||
| 696 | 'newRoleId' => $roleModel->id, |
||
| 697 | ]); |
||
| 698 | |||
| 699 | // No role change needed |
||
| 700 | if ($currentRoleId === $roleModel->id) { |
||
| 701 | return self::handleSameRoleUpdate( |
||
| 702 | $user, |
||
| 703 | $roleModel, |
||
| 704 | $applyPromotions, |
||
| 705 | $addYears, |
||
| 706 | $currentExpiryDate |
||
| 707 | ); |
||
| 708 | } |
||
| 709 | |||
| 710 | // Determine if we should stack |
||
| 711 | $shouldStack = $stackRole && $currentExpiryDate?->isFuture(); |
||
| 712 | |||
| 713 | if ($shouldStack) { |
||
| 714 | return self::handleStackedRoleChange( |
||
| 715 | $user, |
||
| 716 | $roleModel, |
||
| 717 | $currentRoleId, |
||
| 718 | $oldExpiryDate, |
||
| 719 | $currentExpiryDate, |
||
| 720 | $applyPromotions, |
||
| 721 | $addYears, |
||
| 722 | $changedBy |
||
| 723 | ); |
||
| 724 | } |
||
| 725 | |||
| 726 | return self::handleImmediateRoleChange( |
||
| 727 | $user, |
||
| 728 | $roleModel, |
||
| 729 | $currentRoleId, |
||
| 730 | $oldExpiryDate, |
||
| 731 | $currentExpiryDate, |
||
| 732 | $applyPromotions, |
||
| 733 | $addYears, |
||
| 734 | $changedBy, |
||
| 735 | $preserveCurrentExpiry |
||
| 736 | ); |
||
| 737 | } |
||
| 738 | |||
| 739 | /** |
||
| 740 | * Handle role update when role is not changing. |
||
| 741 | */ |
||
| 742 | private static function handleSameRoleUpdate( |
||
| 743 | self $user, |
||
| 744 | Role $roleModel, |
||
| 745 | bool $applyPromotions, |
||
| 746 | ?int $addYears, |
||
| 747 | ?Carbon $currentExpiryDate, |
||
| 748 | ): bool { |
||
| 749 | if (in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true)) { |
||
| 750 | return true; |
||
| 751 | } |
||
| 752 | |||
| 753 | $additionalDays = ($addYears ?? 0) * self::DAYS_PER_YEAR; |
||
| 754 | $promotionDays = $applyPromotions |
||
| 755 | ? RolePromotion::calculateAdditionalDays($roleModel->id) |
||
| 756 | : 0; |
||
| 757 | $totalDays = $additionalDays + $promotionDays; |
||
| 758 | |||
| 759 | if ($totalDays <= 0) { |
||
| 760 | return true; |
||
| 761 | } |
||
| 762 | |||
| 763 | $newExpiryDate = $currentExpiryDate?->isFuture() |
||
| 764 | ? $currentExpiryDate->copy()->addDays($totalDays) |
||
| 765 | : now()->addDays($totalDays); |
||
| 766 | |||
| 767 | $updated = $user->update(['rolechangedate' => $newExpiryDate]); |
||
| 768 | |||
| 769 | if ($updated && $promotionDays > 0) { |
||
| 770 | self::trackPromotionApplication($user, $roleModel->id, $currentExpiryDate, $newExpiryDate); |
||
| 771 | } |
||
| 772 | |||
| 773 | return $updated; |
||
| 774 | } |
||
| 775 | |||
| 776 | /** |
||
| 777 | * Handle stacked role change. |
||
| 778 | */ |
||
| 779 | private static function handleStackedRoleChange( |
||
| 780 | self $user, |
||
| 781 | Role $roleModel, |
||
| 782 | int $currentRoleId, |
||
| 783 | ?Carbon $oldExpiryDate, |
||
| 784 | Carbon $currentExpiryDate, |
||
| 785 | bool $applyPromotions, |
||
| 786 | ?int $addYears, |
||
| 787 | ?int $changedBy, |
||
| 788 | ): bool { |
||
| 789 | // Determine the stacking start date |
||
| 790 | // If user already has a pending stacked role, we need to calculate when THAT role would expire |
||
| 791 | // and use that as the starting point for this new stacked role |
||
| 792 | |||
| 793 | $stackingStartDate = $currentExpiryDate; |
||
| 794 | |||
| 795 | // Check if there's already a pending role - if so, calculate when it would expire |
||
| 796 | if ($user->hasPendingRole()) { |
||
| 797 | $pendingRole = $user->getPendingRole(); |
||
| 798 | $pendingStartDate = $user->pending_role_start_date; |
||
| 799 | |||
| 800 | if ($pendingRole && $pendingStartDate) { |
||
| 801 | // Calculate when the pending role would expire |
||
| 802 | $pendingRoleBaseDays = $pendingRole->addyears * self::DAYS_PER_YEAR; |
||
| 803 | $pendingRolePromotionDays = ! in_array($pendingRole->name, self::PROMOTION_EXCLUDED_ROLES, true) |
||
| 804 | ? RolePromotion::calculateAdditionalDays($pendingRole->id) |
||
| 805 | : 0; |
||
| 806 | $pendingRoleTotalDays = $pendingRoleBaseDays + $pendingRolePromotionDays; |
||
| 807 | $pendingRoleExpiryDate = Carbon::parse($pendingStartDate)->addDays($pendingRoleTotalDays); |
||
| 808 | |||
| 809 | Log::info('Existing pending role detected - calculating new stacking start date', [ |
||
| 810 | 'pendingRoleId' => $pendingRole->id, |
||
| 811 | 'pendingRoleName' => $pendingRole->name, |
||
| 812 | 'pendingStartDate' => $pendingStartDate->toDateTimeString(), |
||
| 813 | 'pendingRoleBaseDays' => $pendingRoleBaseDays, |
||
| 814 | 'pendingRolePromotionDays' => $pendingRolePromotionDays, |
||
| 815 | 'pendingRoleExpiryDate' => $pendingRoleExpiryDate->toDateTimeString(), |
||
| 816 | ]); |
||
| 817 | |||
| 818 | // Use the pending role's expiry date as the new stacking start date |
||
| 819 | if ($pendingRoleExpiryDate->isFuture()) { |
||
| 820 | $stackingStartDate = $pendingRoleExpiryDate; |
||
| 821 | } |
||
| 822 | } |
||
| 823 | } else { |
||
| 824 | // No pending role - use the latest of oldExpiryDate and currentExpiryDate |
||
| 825 | Log::info('No pending role - comparing old and current expiry dates', [ |
||
| 826 | 'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(), |
||
| 827 | 'currentExpiryDate' => $currentExpiryDate->toDateTimeString(), |
||
| 828 | ]); |
||
| 829 | |||
| 830 | // Use the greater of the two dates if old expiry exists and is in the future |
||
| 831 | if ($oldExpiryDate !== null && $oldExpiryDate->isFuture() && $oldExpiryDate->gt($currentExpiryDate)) { |
||
| 832 | $stackingStartDate = $oldExpiryDate; |
||
| 833 | } |
||
| 834 | } |
||
| 835 | |||
| 836 | Log::info('Final stacking start date selected', [ |
||
| 837 | 'stackingStartDate' => $stackingStartDate->toDateTimeString(), |
||
| 838 | 'hadPendingRole' => $user->hasPendingRole(), |
||
| 839 | ]); |
||
| 840 | |||
| 841 | $baseDays = ($addYears ?? $roleModel->addyears) * self::DAYS_PER_YEAR; |
||
| 842 | $promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions |
||
| 843 | ? RolePromotion::calculateAdditionalDays($roleModel->id) |
||
| 844 | : 0; |
||
| 845 | $totalDays = $baseDays + $promotionDays; |
||
| 846 | $newExpiryDate = $stackingStartDate->copy()->addDays($totalDays); |
||
| 847 | |||
| 848 | $user->update([ |
||
| 849 | 'pending_roles_id' => $roleModel->id, |
||
| 850 | 'pending_role_start_date' => $stackingStartDate, |
||
| 851 | ]); |
||
| 852 | |||
| 853 | try { |
||
| 854 | UserRoleHistory::recordRoleChange( |
||
| 855 | userId: $user->id, |
||
| 856 | oldRoleId: $currentRoleId, |
||
| 857 | newRoleId: $roleModel->id, |
||
| 858 | oldExpiryDate: $stackingStartDate, |
||
| 859 | newExpiryDate: $newExpiryDate, |
||
| 860 | effectiveDate: $stackingStartDate, |
||
| 861 | isStacked: true, |
||
| 862 | changeReason: 'stacked_role_change', |
||
| 863 | changedBy: $changedBy |
||
| 864 | ); |
||
| 865 | } catch (\Exception $e) { |
||
| 866 | Log::error('Failed to record role history', ['error' => $e->getMessage()]); |
||
| 867 | } |
||
| 868 | |||
| 869 | return true; |
||
| 870 | } |
||
| 871 | |||
| 872 | /** |
||
| 873 | * Handle immediate role change. |
||
| 874 | */ |
||
| 875 | private static function handleImmediateRoleChange( |
||
| 876 | self $user, |
||
| 877 | Role $roleModel, |
||
| 878 | int $currentRoleId, |
||
| 879 | ?Carbon $oldExpiryDate, |
||
| 880 | ?Carbon $currentExpiryDate, |
||
| 881 | bool $applyPromotions, |
||
| 882 | ?int $addYears, |
||
| 883 | ?int $changedBy, |
||
| 884 | bool $preserveCurrentExpiry, |
||
| 885 | ): bool { |
||
| 886 | $baseDays = ($addYears ?? $roleModel->addyears) * self::DAYS_PER_YEAR; |
||
| 887 | $promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions |
||
| 888 | ? RolePromotion::calculateAdditionalDays($roleModel->id) |
||
| 889 | : 0; |
||
| 890 | $totalDays = $baseDays + $promotionDays; |
||
| 891 | |||
| 892 | $newExpiryDate = match (true) { |
||
| 893 | $preserveCurrentExpiry && $currentExpiryDate !== null => $currentExpiryDate, |
||
| 894 | $totalDays > 0 => now()->addDays($totalDays), |
||
| 895 | default => null, |
||
| 896 | }; |
||
| 897 | |||
| 898 | $updated = $user->update([ |
||
| 899 | 'roles_id' => $roleModel->id, |
||
| 900 | 'rolechangedate' => $newExpiryDate, |
||
| 901 | ]); |
||
| 902 | |||
| 903 | $user->syncRoles([$roleModel->name]); |
||
| 904 | |||
| 905 | if ($updated) { |
||
| 906 | try { |
||
| 907 | UserRoleHistory::recordRoleChange( |
||
| 908 | userId: $user->id, |
||
| 909 | oldRoleId: $currentRoleId, |
||
| 910 | newRoleId: $roleModel->id, |
||
| 911 | oldExpiryDate: $oldExpiryDate, |
||
| 912 | newExpiryDate: $newExpiryDate, |
||
| 913 | effectiveDate: now(), |
||
| 914 | isStacked: false, |
||
| 915 | changeReason: 'immediate_role_change', |
||
| 916 | changedBy: $changedBy |
||
| 917 | ); |
||
| 918 | } catch (\Exception $e) { |
||
| 919 | Log::error('Failed to record role history', ['error' => $e->getMessage()]); |
||
| 920 | } |
||
| 921 | |||
| 922 | if ($applyPromotions && $promotionDays > 0) { |
||
| 923 | self::trackPromotionApplication($user, $roleModel->id, $oldExpiryDate, $newExpiryDate); |
||
| 924 | } |
||
| 925 | } |
||
| 926 | |||
| 927 | return $updated; |
||
| 928 | } |
||
| 929 | |||
| 930 | /** |
||
| 931 | * Track promotion application statistics. |
||
| 932 | */ |
||
| 933 | private static function trackPromotionApplication( |
||
| 934 | self $user, |
||
| 935 | int $roleId, |
||
| 936 | ?Carbon $oldExpiryDate, |
||
| 937 | ?Carbon $newExpiryDate, |
||
| 938 | ): void { |
||
| 939 | $promotions = RolePromotion::getActivePromotions($roleId); |
||
| 940 | foreach ($promotions as $promotion) { |
||
| 941 | $promotion->trackApplication($user->id, $roleId, $oldExpiryDate, $newExpiryDate); |
||
| 942 | } |
||
| 943 | } |
||
| 944 | |||
| 945 | /** |
||
| 946 | * Process expired roles and send notifications. |
||
| 947 | */ |
||
| 948 | public static function updateExpiredRoles(): void |
||
| 949 | { |
||
| 950 | $now = CarbonImmutable::now(); |
||
| 951 | |||
| 952 | // Send expiration warnings |
||
| 953 | self::sendExpirationWarnings($now); |
||
| 954 | |||
| 955 | // Process expired roles |
||
| 956 | self::processExpiredRoles($now); |
||
| 957 | } |
||
| 958 | |||
| 959 | /** |
||
| 960 | * Send expiration warning emails. |
||
| 961 | */ |
||
| 962 | private static function sendExpirationWarnings(CarbonImmutable $now): void |
||
| 963 | { |
||
| 964 | $periods = [ |
||
| 965 | 'day' => $now->addDay(), |
||
| 966 | 'week' => $now->addWeek(), |
||
| 967 | 'month' => $now->addMonth(), |
||
| 968 | ]; |
||
| 969 | |||
| 970 | foreach ($periods as $period) { |
||
| 971 | $users = static::whereDate('rolechangedate', '=', $period)->get(); |
||
| 972 | $days = $now->diffInDays($period, true); |
||
| 973 | |||
| 974 | foreach ($users as $user) { |
||
| 975 | SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails'); |
||
| 976 | } |
||
| 977 | } |
||
| 978 | } |
||
| 979 | |||
| 980 | /** |
||
| 981 | * Process users with expired roles. |
||
| 982 | */ |
||
| 983 | private static function processExpiredRoles(CarbonImmutable $now): void |
||
| 984 | { |
||
| 985 | static::expired()->each(function (self $user) use ($now) { |
||
| 986 | $oldRoleId = $user->roles_id; |
||
| 987 | $oldExpiryDate = $user->rolechangedate; |
||
| 988 | |||
| 989 | // Check for pending stacked role |
||
| 990 | if ($user->hasPendingRole() && $user->pending_role_start_date?->lte($now)) { |
||
| 991 | self::activatePendingRole($user, $oldRoleId, $oldExpiryDate, $now); |
||
| 992 | |||
| 993 | return; |
||
| 994 | } |
||
| 995 | |||
| 996 | // Downgrade to default user role |
||
| 997 | self::downgradeToDefaultRole($user, $oldRoleId, $oldExpiryDate, $now); |
||
| 998 | }); |
||
| 999 | } |
||
| 1000 | |||
| 1001 | /** |
||
| 1002 | * Activate a pending stacked role. |
||
| 1003 | */ |
||
| 1004 | private static function activatePendingRole( |
||
| 1005 | self $user, |
||
| 1006 | int $oldRoleId, |
||
| 1007 | ?Carbon $oldExpiryDate, |
||
| 1008 | CarbonImmutable $now, |
||
| 1009 | ): void { |
||
| 1010 | $roleModel = Role::find($user->pending_roles_id); |
||
| 1011 | if (! $roleModel) { |
||
| 1012 | return; |
||
| 1013 | } |
||
| 1014 | |||
| 1015 | $baseDays = $roleModel->addyears * self::DAYS_PER_YEAR; |
||
| 1016 | $promotionDays = RolePromotion::calculateAdditionalDays($roleModel->id); |
||
| 1017 | $totalDays = $baseDays + $promotionDays; |
||
| 1018 | $newExpiryDate = $totalDays > 0 ? $now->addDays($totalDays) : null; |
||
| 1019 | |||
| 1020 | $user->update([ |
||
| 1021 | 'roles_id' => $roleModel->id, |
||
| 1022 | 'rolechangedate' => $newExpiryDate, |
||
| 1023 | 'pending_roles_id' => null, |
||
| 1024 | 'pending_role_start_date' => null, |
||
| 1025 | ]); |
||
| 1026 | $user->syncRoles([$roleModel->name]); |
||
| 1027 | |||
| 1028 | UserRoleHistory::recordRoleChange( |
||
| 1029 | userId: $user->id, |
||
| 1030 | oldRoleId: $oldRoleId, |
||
| 1031 | newRoleId: $roleModel->id, |
||
| 1032 | oldExpiryDate: $oldExpiryDate, |
||
| 1033 | newExpiryDate: $newExpiryDate, |
||
| 1034 | effectiveDate: Carbon::instance($now), |
||
| 1035 | isStacked: true, |
||
| 1036 | changeReason: 'stacked_role_activated', |
||
| 1037 | changedBy: null |
||
| 1038 | ); |
||
| 1039 | } |
||
| 1040 | |||
| 1041 | /** |
||
| 1042 | * Downgrade user to default role. |
||
| 1043 | */ |
||
| 1044 | private static function downgradeToDefaultRole( |
||
| 1045 | self $user, |
||
| 1046 | int $oldRoleId, |
||
| 1047 | ?Carbon $oldExpiryDate, |
||
| 1048 | CarbonImmutable $now, |
||
| 1049 | ): void { |
||
| 1050 | $user->update([ |
||
| 1051 | 'roles_id' => UserRole::USER->value, |
||
| 1052 | 'rolechangedate' => null, |
||
| 1053 | 'pending_roles_id' => null, |
||
| 1054 | 'pending_role_start_date' => null, |
||
| 1055 | ]); |
||
| 1056 | $user->syncRoles(['User']); |
||
| 1057 | |||
| 1058 | UserRoleHistory::recordRoleChange( |
||
| 1059 | userId: $user->id, |
||
| 1060 | oldRoleId: $oldRoleId, |
||
| 1061 | newRoleId: UserRole::USER->value, |
||
| 1062 | oldExpiryDate: $oldExpiryDate, |
||
| 1063 | newExpiryDate: null, |
||
| 1064 | effectiveDate: Carbon::instance($now), |
||
| 1065 | isStacked: false, |
||
| 1066 | changeReason: 'role_expired', |
||
| 1067 | changedBy: null |
||
| 1068 | ); |
||
| 1069 | |||
| 1070 | SendAccountExpiredEmail::dispatch($user)->onQueue('emails'); |
||
| 1071 | } |
||
| 1072 | |||
| 1073 | /** |
||
| 1074 | * Get paginated user list with filters. |
||
| 1075 | * |
||
| 1076 | * @return Collection<int, static> |
||
| 1077 | * @throws \Throwable |
||
| 1078 | */ |
||
| 1079 | public static function getRange( |
||
| 1080 | int|false $start, |
||
| 1081 | int $offset, |
||
| 1082 | string $orderBy, |
||
| 1083 | ?string $userName = '', |
||
| 1084 | ?string $email = '', |
||
| 1085 | ?string $host = '', |
||
| 1086 | ?string $role = '', |
||
| 1087 | bool $apiRequests = false, |
||
| 1088 | ?string $createdFrom = '', |
||
| 1089 | ?string $createdTo = '', |
||
| 1090 | ): Collection { |
||
| 1091 | $order = self::getBrowseOrder($orderBy); |
||
| 1092 | |||
| 1093 | if ($apiRequests) { |
||
| 1094 | UserRequest::clearApiRequests(false); |
||
| 1095 | |||
| 1096 | $query = ' |
||
| 1097 | SELECT users.*, roles.name AS rolename, COUNT(user_requests.id) AS apirequests |
||
| 1098 | FROM users |
||
| 1099 | INNER JOIN roles ON roles.id = users.roles_id |
||
| 1100 | LEFT JOIN user_requests ON user_requests.users_id = users.id |
||
| 1101 | WHERE users.id != 0 %s %s %s %s %s %s |
||
| 1102 | AND email != \'[email protected]\' |
||
| 1103 | GROUP BY users.id |
||
| 1104 | ORDER BY %s %s %s'; |
||
| 1105 | } else { |
||
| 1106 | $query = ' |
||
| 1107 | SELECT users.*, roles.name AS rolename |
||
| 1108 | FROM users |
||
| 1109 | INNER JOIN roles ON roles.id = users.roles_id |
||
| 1110 | WHERE 1=1 %s %s %s %s %s %s |
||
| 1111 | ORDER BY %s %s %s'; |
||
| 1112 | } |
||
| 1113 | |||
| 1114 | return static::fromQuery( |
||
| 1115 | sprintf( |
||
| 1116 | $query, |
||
| 1117 | $userName ? 'AND users.username LIKE '.escapeString("%{$userName}%") : '', |
||
| 1118 | $email ? 'AND users.email LIKE '.escapeString("%{$email}%") : '', |
||
| 1119 | $host ? 'AND users.host LIKE '.escapeString("%{$host}%") : '', |
||
| 1120 | $role ? "AND users.roles_id = {$role}" : '', |
||
| 1121 | $createdFrom ? 'AND users.created_at >= '.escapeString("{$createdFrom} 00:00:00") : '', |
||
| 1122 | $createdTo ? 'AND users.created_at <= '.escapeString("{$createdTo} 23:59:59") : '', |
||
| 1123 | $order[0], |
||
| 1124 | $order[1], |
||
| 1125 | $start === false ? '' : "LIMIT {$offset} OFFSET {$start}" |
||
| 1126 | ) |
||
| 1127 | ); |
||
| 1128 | } |
||
| 1129 | |||
| 1130 | /** |
||
| 1131 | * Get sort configuration for user browsing. |
||
| 1132 | * |
||
| 1133 | * @return array{0: string, 1: string} |
||
| 1134 | */ |
||
| 1135 | public static function getBrowseOrder(string $orderBy = ''): array |
||
| 1136 | { |
||
| 1137 | $order = $orderBy ?: 'username_desc'; |
||
| 1138 | $parts = explode('_', $order); |
||
| 1139 | |||
| 1140 | $field = match ($parts[0]) { |
||
| 1141 | 'email' => 'email', |
||
| 1142 | 'host' => 'host', |
||
| 1143 | 'createdat' => 'created_at', |
||
| 1144 | 'lastlogin' => 'lastlogin', |
||
| 1145 | 'apiaccess' => 'apiaccess', |
||
| 1146 | 'grabs' => 'grabs', |
||
| 1147 | 'role' => 'rolename', |
||
| 1148 | 'rolechangedate' => 'rolechangedate', |
||
| 1149 | 'verification' => 'verified', |
||
| 1150 | default => 'username', |
||
| 1151 | }; |
||
| 1152 | |||
| 1153 | $direction = isset($parts[1]) && preg_match('/^asc|desc$/i', $parts[1]) |
||
| 1154 | ? $parts[1] |
||
| 1155 | : 'desc'; |
||
| 1156 | |||
| 1157 | return [$field, $direction]; |
||
| 1158 | } |
||
| 1159 | |||
| 1160 | // ===== Password Methods ===== |
||
| 1161 | |||
| 1162 | /** |
||
| 1163 | * Verify password and rehash if needed. |
||
| 1164 | */ |
||
| 1165 | public static function checkPassword(string $password, string $hash, int $userId = -1): bool |
||
| 1166 | { |
||
| 1167 | if (! Hash::check($password, $hash)) { |
||
| 1168 | return false; |
||
| 1169 | } |
||
| 1170 | |||
| 1171 | if ($userId > 0 && Hash::needsRehash($hash)) { |
||
| 1172 | static::find($userId)?->update(['password' => Hash::make($password)]); |
||
| 1173 | } |
||
| 1174 | |||
| 1175 | return true; |
||
| 1176 | } |
||
| 1177 | |||
| 1178 | /** |
||
| 1179 | * Hash a password. |
||
| 1180 | */ |
||
| 1181 | public static function hashPassword(string $password): string |
||
| 1182 | { |
||
| 1183 | return Hash::make($password); |
||
| 1184 | } |
||
| 1185 | |||
| 1186 | /** |
||
| 1187 | * Generate a secure random password. |
||
| 1188 | * |
||
| 1189 | * @throws \Exception |
||
| 1190 | */ |
||
| 1191 | public static function generatePassword(int $length = 15): string |
||
| 1192 | { |
||
| 1193 | return Str::password($length); |
||
| 1194 | } |
||
| 1195 | |||
| 1196 | // ===== Update Methods ===== |
||
| 1197 | |||
| 1198 | /** |
||
| 1199 | * Regenerate RSS key. |
||
| 1200 | */ |
||
| 1201 | public static function updateRssKey(int $uid): int |
||
| 1202 | { |
||
| 1203 | static::find($uid)?->update([ |
||
| 1204 | 'api_token' => md5(Password::getRepository()->createNewToken()), |
||
| 1205 | ]); |
||
| 1206 | |||
| 1207 | return SignupError::SUCCESS->value; |
||
| 1208 | } |
||
| 1209 | |||
| 1210 | /** |
||
| 1211 | * Update password reset GUID. |
||
| 1212 | */ |
||
| 1213 | public static function updatePassResetGuid(int $id, ?string $guid): int |
||
| 1214 | { |
||
| 1215 | static::find($id)?->update(['resetguid' => $guid]); |
||
| 1216 | |||
| 1217 | return SignupError::SUCCESS->value; |
||
| 1218 | } |
||
| 1219 | |||
| 1220 | /** |
||
| 1221 | * Update user password. |
||
| 1222 | */ |
||
| 1223 | public static function updatePassword(int $id, string $password): int |
||
| 1224 | { |
||
| 1225 | static::find($id)?->update(['password' => Hash::make($password)]); |
||
| 1226 | |||
| 1227 | return SignupError::SUCCESS->value; |
||
| 1228 | } |
||
| 1229 | |||
| 1230 | /** |
||
| 1231 | * Update user role change date. |
||
| 1232 | */ |
||
| 1233 | public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int |
||
| 1234 | { |
||
| 1235 | static::find($id)?->update(['rolechangedate' => $roleChangeDate]); |
||
| 1236 | |||
| 1237 | return SignupError::SUCCESS->value; |
||
| 1238 | } |
||
| 1239 | |||
| 1240 | /** |
||
| 1241 | * Increment user's grab count. |
||
| 1242 | */ |
||
| 1243 | public static function incrementGrabs(int $id, int $num = 1): void |
||
| 1244 | { |
||
| 1245 | static::find($id)?->increment('grabs', $num); |
||
| 1246 | } |
||
| 1247 | |||
| 1248 | // ===== Validation Methods ===== |
||
| 1249 | |||
| 1250 | /** |
||
| 1251 | * Validate a URL format. |
||
| 1252 | */ |
||
| 1253 | public static function isValidUrl(string $url): bool |
||
| 1254 | { |
||
| 1255 | return (bool) preg_match( |
||
| 1256 | '/^(https?|ftp):\/\/([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+):?(\d+)?\/?/i', |
||
| 1257 | $url |
||
| 1258 | ); |
||
| 1259 | } |
||
| 1260 | |||
| 1261 | // ===== Registration Methods ===== |
||
| 1262 | |||
| 1263 | /** |
||
| 1264 | * Register a new user. |
||
| 1265 | * |
||
| 1266 | * @throws \Exception |
||
| 1267 | */ |
||
| 1268 | public static function signUp( |
||
| 1269 | string $userName, |
||
| 1270 | string $password, |
||
| 1271 | string $email, |
||
| 1272 | string $host, |
||
| 1273 | ?string $notes, |
||
| 1274 | int $invites = Invitation::DEFAULT_INVITES, |
||
| 1275 | string $inviteCode = '', |
||
| 1276 | bool $forceInviteMode = false, |
||
| 1277 | int $role = UserRole::USER->value, |
||
| 1278 | bool $validate = true, |
||
| 1279 | ): bool|int|string { |
||
| 1280 | $userData = [ |
||
| 1281 | 'username' => trim($userName), |
||
| 1282 | 'password' => trim($password), |
||
| 1283 | 'email' => trim($email), |
||
| 1284 | ]; |
||
| 1285 | |||
| 1286 | if ($validate) { |
||
| 1287 | $validator = Validator::make($userData, [ |
||
| 1288 | 'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'], |
||
| 1289 | 'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new ValidEmailDomain], |
||
| 1290 | 'password' => [ |
||
| 1291 | 'required', |
||
| 1292 | 'string', |
||
| 1293 | 'min:8', |
||
| 1294 | 'confirmed', |
||
| 1295 | 'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/', |
||
| 1296 | ], |
||
| 1297 | ]); |
||
| 1298 | |||
| 1299 | if ($validator->fails()) { |
||
| 1300 | return implode('', Arr::collapse($validator->errors()->toArray())); |
||
| 1301 | } |
||
| 1302 | } |
||
| 1303 | |||
| 1304 | $invitedBy = 0; |
||
| 1305 | if (! $forceInviteMode && (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) { |
||
| 1306 | if ($inviteCode === '') { |
||
| 1307 | return SignupError::BAD_INVITE_CODE->value; |
||
| 1308 | } |
||
| 1309 | |||
| 1310 | $invitedBy = self::checkAndUseInvite($inviteCode); |
||
| 1311 | if ($invitedBy < 0) { |
||
| 1312 | return SignupError::BAD_INVITE_CODE->value; |
||
| 1313 | } |
||
| 1314 | } |
||
| 1315 | |||
| 1316 | return self::add( |
||
| 1317 | $userData['username'], |
||
| 1318 | $userData['password'], |
||
| 1319 | $userData['email'], |
||
| 1320 | $role, |
||
| 1321 | $notes, |
||
| 1322 | $host, |
||
| 1323 | $invites, |
||
| 1324 | $invitedBy |
||
| 1325 | ); |
||
| 1326 | } |
||
| 1327 | |||
| 1328 | /** |
||
| 1329 | * Validate and consume an invite code. |
||
| 1330 | */ |
||
| 1331 | public static function checkAndUseInvite(string $inviteCode): int |
||
| 1332 | { |
||
| 1333 | $invite = Invitation::findValidByToken($inviteCode); |
||
| 1334 | if (! $invite) { |
||
| 1335 | return -1; |
||
| 1336 | } |
||
| 1337 | |||
| 1338 | static::where('id', $invite->invited_by)->decrement('invites'); |
||
| 1339 | $invite->markAsUsed(0); |
||
| 1340 | |||
| 1341 | return $invite->invited_by; |
||
| 1342 | } |
||
| 1343 | |||
| 1344 | /** |
||
| 1345 | * Create a new user. |
||
| 1346 | * |
||
| 1347 | * @return int|false |
||
| 1348 | */ |
||
| 1349 | public static function add( |
||
| 1350 | string $userName, |
||
| 1351 | string $password, |
||
| 1352 | string $email, |
||
| 1353 | int $role, |
||
| 1354 | ?string $notes = '', |
||
| 1355 | string $host = '', |
||
| 1356 | int $invites = Invitation::DEFAULT_INVITES, |
||
| 1357 | int $invitedBy = 0, |
||
| 1358 | ): int|false { |
||
| 1359 | $hashedPassword = Hash::make($password); |
||
| 1360 | |||
| 1361 | $storeIps = config('nntmux:settings.store_user_ip') === true ? $host : ''; |
||
| 1362 | |||
| 1363 | $user = static::create([ |
||
| 1364 | 'username' => $userName, |
||
| 1365 | 'password' => $hashedPassword, |
||
| 1366 | 'email' => $email, |
||
| 1367 | 'host' => $storeIps, |
||
| 1368 | 'roles_id' => $role, |
||
| 1369 | 'invites' => $invites, |
||
| 1370 | 'invitedby' => $invitedBy === 0 ? null : $invitedBy, |
||
| 1371 | 'notes' => $notes, |
||
| 1372 | ]); |
||
| 1373 | |||
| 1374 | return $user->id; |
||
| 1375 | } |
||
| 1376 | |||
| 1377 | // ===== Category Exclusion Methods ===== |
||
| 1378 | |||
| 1379 | /** |
||
| 1380 | * Get excluded category IDs for a user. |
||
| 1381 | * |
||
| 1382 | * @return array<int> |
||
| 1383 | * @throws \Exception |
||
| 1384 | */ |
||
| 1385 | public static function getCategoryExclusionById(int $userId): array |
||
| 1386 | { |
||
| 1387 | $user = static::findOrFail($userId); |
||
| 1388 | |||
| 1389 | $userAllowed = $user->getDirectPermissions()->pluck('name')->toArray(); |
||
| 1390 | $roleAllowed = $user->getAllPermissions()->pluck('name')->toArray(); |
||
| 1391 | $allowed = array_intersect($roleAllowed, $userAllowed); |
||
| 1392 | |||
| 1393 | $categoryPermissions = [ |
||
| 1394 | 'view console' => 1000, |
||
| 1395 | 'view movies' => 2000, |
||
| 1396 | 'view audio' => 3000, |
||
| 1397 | 'view pc' => 4000, |
||
| 1398 | 'view tv' => 5000, |
||
| 1399 | 'view adult' => 6000, |
||
| 1400 | 'view books' => 7000, |
||
| 1401 | 'view other' => 1, |
||
| 1402 | ]; |
||
| 1403 | |||
| 1404 | $excludedRoots = []; |
||
| 1405 | foreach ($categoryPermissions as $permission => $rootId) { |
||
| 1406 | if (! in_array($permission, $allowed, false)) { |
||
| 1407 | $excludedRoots[] = $rootId; |
||
| 1408 | } |
||
| 1409 | } |
||
| 1410 | |||
| 1411 | return Category::whereIn('root_categories_id', $excludedRoots) |
||
| 1412 | ->pluck('id') |
||
| 1413 | ->toArray(); |
||
| 1414 | } |
||
| 1415 | |||
| 1416 | /** |
||
| 1417 | * Get excluded categories for API request. |
||
| 1418 | * |
||
| 1419 | * @throws \Exception |
||
| 1420 | */ |
||
| 1421 | public static function getCategoryExclusionForApi(Request $request): array |
||
| 1422 | { |
||
| 1423 | $apiToken = $request->input('api_token') ?? $request->input('apikey'); |
||
| 1424 | $user = static::findByRssToken($apiToken); |
||
| 1425 | |||
| 1426 | return $user ? static::getCategoryExclusionById($user->id) : []; |
||
| 1427 | } |
||
| 1428 | |||
| 1429 | // ===== Invitation Methods ===== |
||
| 1430 | |||
| 1431 | /** |
||
| 1432 | * Send an invitation email. |
||
| 1433 | * |
||
| 1434 | * @throws \Exception |
||
| 1435 | */ |
||
| 1436 | public static function sendInvite(string $serverUrl, int $uid, string $emailTo): string |
||
| 1437 | { |
||
| 1438 | $user = static::findOrFail($uid); |
||
| 1439 | |||
| 1440 | $invitation = Invitation::createInvitation($emailTo, $user->id); |
||
| 1441 | $url = "{$serverUrl}/register?token={$invitation->token}"; |
||
| 1442 | |||
| 1443 | app(InvitationService::class)->sendInvitationEmail($invitation); |
||
| 1444 | |||
| 1445 | return $url; |
||
| 1446 | } |
||
| 1447 | |||
| 1448 | // ===== Cleanup Methods ===== |
||
| 1449 | |||
| 1450 | /** |
||
| 1451 | * Delete unverified users older than 3 days. |
||
| 1452 | */ |
||
| 1453 | public static function deleteUnVerified(): void |
||
| 1454 | { |
||
| 1455 | static::whereVerified(0) |
||
| 1456 | ->where('created_at', '<', now()->subDays(3)) |
||
| 1457 | ->delete(); |
||
| 1458 | } |
||
| 1459 | |||
| 1460 | /** |
||
| 1461 | * Check if user can post. |
||
| 1462 | */ |
||
| 1463 | public static function canPost(int $userId): bool |
||
| 1464 | { |
||
| 1465 | return (bool) static::where('id', $userId)->value('can_post'); |
||
| 1466 | } |
||
| 1467 | |||
| 1468 | /** |
||
| 1469 | * Get the user's timezone. |
||
| 1470 | */ |
||
| 1471 | public function getTimezone(): string |
||
| 1472 | { |
||
| 1473 | return $this->timezone ?? 'UTC'; |
||
| 1474 | } |
||
| 1475 | |||
| 1476 | // ===== Legacy Constants (Deprecated - Use Enums) ===== |
||
| 1477 | |||
| 1478 | /** @deprecated Use SignupError::BAD_USERNAME->value instead */ |
||
| 1479 | public const ERR_SIGNUP_BADUNAME = SignupError::BAD_USERNAME->value; |
||
| 1480 | |||
| 1481 | /** @deprecated Use SignupError::BAD_PASSWORD->value instead */ |
||
| 1482 | public const ERR_SIGNUP_BADPASS = SignupError::BAD_PASSWORD->value; |
||
| 1483 | |||
| 1484 | /** @deprecated Use SignupError::BAD_EMAIL->value instead */ |
||
| 1485 | public const ERR_SIGNUP_BADEMAIL = SignupError::BAD_EMAIL->value; |
||
| 1486 | |||
| 1487 | /** @deprecated Use SignupError::USERNAME_IN_USE->value instead */ |
||
| 1488 | public const ERR_SIGNUP_UNAMEINUSE = SignupError::USERNAME_IN_USE->value; |
||
| 1489 | |||
| 1490 | /** @deprecated Use SignupError::EMAIL_IN_USE->value instead */ |
||
| 1491 | public const ERR_SIGNUP_EMAILINUSE = SignupError::EMAIL_IN_USE->value; |
||
| 1492 | |||
| 1493 | /** @deprecated Use SignupError::BAD_INVITE_CODE->value instead */ |
||
| 1494 | public const ERR_SIGNUP_BADINVITECODE = SignupError::BAD_INVITE_CODE->value; |
||
| 1495 | |||
| 1496 | /** @deprecated Use SignupError::SUCCESS->value instead */ |
||
| 1497 | public const SUCCESS = SignupError::SUCCESS->value; |
||
| 1498 | |||
| 1499 | /** @deprecated Use UserRole::USER->value instead */ |
||
| 1500 | public const ROLE_USER = UserRole::USER->value; |
||
| 1501 | |||
| 1502 | /** @deprecated Use UserRole::ADMIN->value instead */ |
||
| 1503 | public const ROLE_ADMIN = UserRole::ADMIN->value; |
||
| 1504 | |||
| 1505 | /** @deprecated Use UserRole::DISABLED->value instead */ |
||
| 1506 | public const ROLE_DISABLED = UserRole::DISABLED->value; |
||
| 1507 | |||
| 1508 | /** @deprecated Use UserRole::MODERATOR->value instead */ |
||
| 1509 | public const ROLE_MODERATOR = UserRole::MODERATOR->value; |
||
| 1510 | |||
| 1511 | /** @deprecated Use QueueType::NONE->value instead */ |
||
| 1512 | public const QUEUE_NONE = QueueType::NONE->value; |
||
| 1513 | |||
| 1514 | /** @deprecated Use QueueType::SABNZBD->value instead */ |
||
| 1515 | public const QUEUE_SABNZBD = QueueType::SABNZBD->value; |
||
| 1516 | |||
| 1517 | /** @deprecated Use QueueType::NZBGET->value instead */ |
||
| 1518 | public const QUEUE_NZBGET = QueueType::NZBGET->value; |
||
| 1519 | } |
||
| 1520 |