Passed
Push — master ( 49b4e9...c9293f )
by Darko
11:23
created

User::updateExpiredRoles()   B

Complexity

Conditions 10
Paths 33

Size

Total Lines 91
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 57
c 0
b 0
f 0
dl 0
loc 91
rs 7.0715
cc 10
nc 33
nop 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace App\Models;
4
5
use App\Jobs\SendAccountExpiredEmail;
6
use App\Jobs\SendAccountWillExpireEmail;
7
use App\Rules\ValidEmailDomain;
8
use App\Services\InvitationService;
9
use Carbon\CarbonImmutable;
10
use Illuminate\Database\Eloquent\Builder;
11
use Illuminate\Database\Eloquent\Collection;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, App\Models\Collection. Consider defining an alias.

Let?s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let?s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
12
use Illuminate\Database\Eloquent\Model;
13
use Illuminate\Database\Eloquent\ModelNotFoundException;
14
use Illuminate\Database\Eloquent\Relations\BelongsTo;
15
use Illuminate\Database\Eloquent\Relations\HasMany;
16
use Illuminate\Database\Eloquent\Relations\HasOne;
17
use Illuminate\Database\Eloquent\SoftDeletes;
18
use Illuminate\Foundation\Auth\User as Authenticatable;
19
use Illuminate\Http\Request;
20
use Illuminate\Notifications\Notifiable;
21
use Illuminate\Support\Arr;
22
use Illuminate\Support\Carbon;
23
use Illuminate\Support\Facades\Hash;
24
use Illuminate\Support\Facades\Password;
25
use Illuminate\Support\Facades\Validator;
26
use Illuminate\Support\Str;
27
use Jrean\UserVerification\Traits\UserVerification;
28
use Spatie\Permission\Models\Role;
29
use Spatie\Permission\Traits\HasRoles;
0 ignored issues
show
Bug introduced by
The type Spatie\Permission\Traits\HasRoles was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
30
31
/**
32
 * App\Models\User.
33
 *
34
 * App\Models\User.
35
 *
36
 * @property int $id
37
 * @property string $username
38
 * @property string|null $firstname
39
 * @property string|null $lastname
40
 * @property string $email
41
 * @property string $password
42
 * @property int $user_roles_id FK to roles.id
43
 * @property string|null $host
44
 * @property int $grabs
45
 * @property string $rsstoken
46
 * @property \Carbon\Carbon|null $created_at
47
 * @property \Carbon\Carbon|null $updated_at
48
 * @property string|null $resetguid
49
 * @property string|null $lastlogin
50
 * @property string|null $apiaccess
51
 * @property int $invites
52
 * @property int|null $invitedby
53
 * @property int $movieview
54
 * @property int $xxxview
55
 * @property int $musicview
56
 * @property int $consoleview
57
 * @property int $bookview
58
 * @property int $gameview
59
 * @property string|null $saburl
60
 * @property string|null $sabapikey
61
 * @property bool|null $sabapikeytype
62
 * @property bool|null $sabpriority
63
 * @property string|null $nzbgeturl
64
 * @property string|null $nzbgetusername
65
 * @property string|null $nzbgetpassword
66
 * @property string|null $nzbvortex_api_key
67
 * @property string|null $nzbvortex_server_url
68
 * @property string $notes
69
 * @property string|null $cp_url
70
 * @property string|null $cp_api
71
 * @property string|null $style
72
 * @property string|null $rolechangedate When does the role expire
73
 * @property string|null $pending_role_start_date When the pending role change takes effect
74
 * @property int|null $pending_roles_id The role that will be applied after current role expires
75
 * @property string|null $remember_token
76
 * @property-read Collection|\App\Models\ReleaseComment[] $comment
77
 * @property-read Collection|\App\Models\UserDownload[] $download
78
 * @property-read Collection|\App\Models\DnzbFailure[] $failedRelease
79
 * @property-read Collection|\App\Models\Invitation[] $invitation
80
 * @property-read \Illuminate\Notifications\DatabaseNotificationCollection|\Illuminate\NotififupdateUsertcations\DatabaseNotification[] $notifications
81
 * @property-read Collection|\App\Models\UsersRelease[] $release
82
 * @property-read Collection|\App\Models\UserRequest[] $request
83
 * @property-read Collection|\App\Models\UserSerie[] $series
84
 *
85
 * @method static Builder|\App\Models\User whereApiaccess($value)
86
 * @method static Builder|\App\Models\User whereBookview($value)
87
 * @method static Builder|\App\Models\User whereConsoleview($value)
88
 * @method static Builder|\App\Models\User whereCpApi($value)
89
 * @method static Builder|\App\Models\User whereCpUrl($value)
90
 * @method static Builder|\App\Models\User whereCreatedAt($value)
91
 * @method static Builder|\App\Models\User whereEmail($value)
92
 * @method static Builder|\App\Models\User whereFirstname($value)
93
 * @method static Builder|\App\Models\User whereGameview($value)
94
 * @method static Builder|\App\Models\User whereGrabs($value)
95
 * @method static Builder|\App\Models\User whereHost($value)
96
 * @method static Builder|\App\Models\User whereId($value)
97
 * @method static Builder|\App\Models\User whereInvitedby($value)
98
 * @method static Builder|\App\Models\User whereInvites($value)
99
 * @method static Builder|\App\Models\User whereLastlogin($value)
100
 * @method static Builder|\App\Models\User whereLastname($value)
101
 * @method static Builder|\App\Models\User whereMovieview($value)
102
 * @method static Builder|\App\Models\User whereMusicview($value)
103
 * @method static Builder|\App\Models\User whereNotes($value)
104
 * @method static Builder|\App\Models\User whereNzbgetpassword($value)
105
 * @method static Builder|\App\Models\User whereNzbgeturl($value)
106
 * @method static Builder|\App\Models\User whereNzbgetusername($value)
107
 * @method static Builder|\App\Models\User whereNzbvortexApiKey($value)
108
 * @method static Builder|\App\Models\User whereNzbvortexServerUrl($value)
109
 * @method static Builder|\App\Models\User wherePassword($value)
110
 * @method static Builder|\App\Models\User whereRememberToken($value)
111
 * @method static Builder|\App\Models\User whereResetguid($value)
112
 * @method static Builder|\App\Models\User whereRolechangedate($value)
113
 * @method static Builder|\App\Models\User whereRsstoken($value)
114
 * @method static Builder|\App\Models\User whereSabapikey($value)
115
 * @method static Builder|\App\Models\User whereSabapikeytype($value)
116
 * @method static Builder|\App\Models\User whereSabpriority($value)
117
 * @method static Builder|\App\Models\User whereSaburl($value)
118
 * @method static Builder|\App\Models\User whereStyle($value)
119
 * @method static Builder|\App\Models\User whereUpdatedAt($value)
120
 * @method static Builder|\App\Models\User whereUserRolesId($value)
121
 * @method static Builder|\App\Models\User whereUsername($value)
122
 * @method static Builder|\App\Models\User whereXxxview($value)
123
 * @method static Builder|\App\Models\User whereVerified($value)
124
 * @method static Builder|\App\Models\User whereApiToken($value)
125
 *
126
 * @mixin \Eloquent
127
 *
128
 * @property int $roles_id FK to roles.id
129
 * @property string $api_token
130
 * @property int $rate_limit
131
 * @property string|null $email_verified_at
132
 * @property int $verified
133
 * @property string|null $verification_token
134
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitationPending
135
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitationSuccess
136
 * @property-read Collection|\Junaidnasir\Larainvite\Models\LaraInviteModel[] $invitations
137
 * @property-read Collection|\Spatie\Permission\Models\Permission[] $permissions
138
 * @property-read Role $role
139
 * @property-read Collection|\Spatie\Permission\Models\Role[] $roles
140
 *
141
 * @method static Builder|\App\Models\User newModelQuery()
142
 * @method static Builder|\App\Models\User newQuery()
143
 * @method static Builder|\App\Models\User permission($permissions)
144
 * @method static Builder|\App\Models\User query()
145
 * @method static Builder|\App\Models\User whereEmailVerifiedAt($value)
146
 * @method static Builder|\App\Models\User whereRateLimit($value)
147
 * @method static Builder|\App\Models\User whereRolesId($value)
148
 * @method static Builder|\App\Models\User whereVerificationToken($value)
149
 */
150
class User extends Authenticatable
151
{
152
    use HasRoles, Notifiable, SoftDeletes, UserVerification;
153
154
    public const ERR_SIGNUP_BADUNAME = -1;
155
156
    public const ERR_SIGNUP_BADPASS = -2;
157
158
    public const ERR_SIGNUP_BADEMAIL = -3;
159
160
    public const ERR_SIGNUP_UNAMEINUSE = -4;
161
162
    public const ERR_SIGNUP_EMAILINUSE = -5;
163
164
    public const ERR_SIGNUP_BADINVITECODE = -6;
165
166
    public const SUCCESS = 1;
167
168
    public const ROLE_USER = 1;
169
170
    public const ROLE_ADMIN = 2;
171
172
    public const ROLE_DISABLED = 3;
173
174
    public const ROLE_MODERATOR = 4;
175
176
    /**
177
     * Users SELECT queue type.
178
     */
179
    public const QUEUE_NONE = 0;
180
181
    public const QUEUE_SABNZBD = 1;
182
183
    public const QUEUE_NZBGET = 2;
184
185
    /**
186
     * @var string
187
     */
188
189
    /**
190
     * @var bool
191
     */
192
    protected $dateFormat = false;
193
194
    /**
195
     * @var array
196
     */
197
    protected $hidden = ['remember_token', 'password'];
198
199
    /**
200
     * @var array
201
     */
202
    protected $guarded = [];
203
204
    protected function getDefaultGuardName(): string
205
    {
206
        return 'web';
207
    }
208
209
    public function role(): BelongsTo
210
    {
211
        return $this->belongsTo(Role::class, 'roles_id');
212
    }
213
214
    public function request(): HasMany
215
    {
216
        return $this->hasMany(UserRequest::class, 'users_id');
217
    }
218
219
    public function download(): HasMany
220
    {
221
        return $this->hasMany(UserDownload::class, 'users_id');
222
    }
223
224
    public function release(): HasMany
225
    {
226
        return $this->hasMany(UsersRelease::class, 'users_id');
227
    }
228
229
    public function series(): HasMany
230
    {
231
        return $this->hasMany(UserSerie::class, 'users_id');
232
    }
233
234
    public function invitation(): HasMany
235
    {
236
        return $this->hasMany(Invitation::class, 'invited_by');
237
    }
238
239
    public function failedRelease(): HasMany
240
    {
241
        return $this->hasMany(DnzbFailure::class, 'users_id');
242
    }
243
244
    public function comment(): HasMany
245
    {
246
        return $this->hasMany(ReleaseComment::class, 'users_id');
247
    }
248
249
    public function promotionStats(): HasMany
250
    {
251
        return $this->hasMany(RolePromotionStat::class);
252
    }
253
254
    public function roleHistory(): HasMany
255
    {
256
        return $this->hasMany(UserRoleHistory::class);
257
    }
258
259
    /**
260
     * Check if user has a pending stacked role
261
     */
262
    public function hasPendingRole(): bool
263
    {
264
        return $this->pending_roles_id !== null && $this->pending_role_start_date !== null;
265
    }
266
267
    /**
268
     * Get the pending role details
269
     */
270
    public function getPendingRole(): ?Role
271
    {
272
        if (!$this->hasPendingRole()) {
273
            return null;
274
        }
275
276
        return Role::find($this->pending_roles_id);
277
    }
278
279
    /**
280
     * Cancel a pending stacked role
281
     */
282
    public function cancelPendingRole(): bool
283
    {
284
        if (!$this->hasPendingRole()) {
285
            return false;
286
        }
287
288
        return $this->update([
289
            'pending_roles_id' => null,
290
            'pending_role_start_date' => null,
291
        ]);
292
    }
293
294
    /**
295
     * Get role expiry information including pending roles
296
     */
297
    public function getRoleExpiryInfo(): array
298
    {
299
        $currentRole = $this->role;
300
        $currentExpiry = $this->rolechangedate ? Carbon::parse($this->rolechangedate) : null;
301
        $pendingRole = $this->getPendingRole();
302
        $pendingStart = $this->pending_role_start_date ? Carbon::parse($this->pending_role_start_date) : null;
303
304
        return [
305
            'current_role' => $currentRole,
306
            'current_expiry' => $currentExpiry,
307
            'has_expiry' => $currentExpiry !== null,
308
            'is_expired' => $currentExpiry ? $currentExpiry->isPast() : false,
309
            'days_until_expiry' => $currentExpiry ? Carbon::now()->diffInDays($currentExpiry, false) : null,
310
            'pending_role' => $pendingRole,
311
            'pending_start' => $pendingStart,
312
            'has_pending_role' => $this->hasPendingRole(),
313
        ];
314
    }
315
316
    /**
317
     * Get the user's timezone or default to UTC
318
     */
319
    public function getTimezone(): string
320
    {
321
        return $this->timezone ?? 'UTC';
322
    }
323
324
    /**
325
     * @throws \Exception
326
     */
327
    public static function deleteUser($id): void
328
    {
329
        self::find($id)->delete();
330
    }
331
332
    public static function getCount(?string $role = null, ?string $username = '', ?string $host = '', ?string $email = '', ?string $createdFrom = '', ?string $createdTo = ''): int
333
    {
334
        $res = self::query()->withTrashed()->where('email', '<>', '[email protected]');
335
336
        if (! empty($role)) {
337
            $res->where('roles_id', $role);
338
        }
339
340
        if ($username !== '') {
341
            $res->where('username', 'like', '%'.$username.'%');
342
        }
343
344
        if ($host !== '') {
345
            $res->where('host', 'like', '%'.$host.'%');
346
        }
347
348
        if ($email !== '') {
349
            $res->where('email', 'like', '%'.$email.'%');
350
        }
351
352
        if ($createdFrom !== '') {
353
            $res->where('created_at', '>=', $createdFrom.' 00:00:00');
354
        }
355
356
        if ($createdTo !== '') {
357
            $res->where('created_at', '<=', $createdTo.' 23:59:59');
358
        }
359
360
        return $res->count(['id']);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $res->count(array('id')) could return the type Illuminate\Database\Eloquent\Builder which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
361
    }
362
363
    public static function updateUser(int $id, string $userName, ?string $email, int $grabs, int $role, ?string $notes, int $invites, int $movieview, int $musicview, int $gameview, int $xxxview, int $consoleview, int $bookview, string $style = 'None'): int
364
    {
365
        $userName = trim($userName);
366
367
        $rateLimit = Role::query()->where('id', $role)->first();
368
369
        $sql = [
370
            'username' => $userName,
371
            'grabs' => $grabs,
372
            'roles_id' => $role,
373
            'notes' => substr($notes, 0, 255),
0 ignored issues
show
Bug introduced by
It seems like $notes can also be of type null; however, parameter $string of substr() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

373
            'notes' => substr(/** @scrutinizer ignore-type */ $notes, 0, 255),
Loading history...
374
            'invites' => $invites,
375
            'movieview' => $movieview,
376
            'musicview' => $musicview,
377
            'gameview' => $gameview,
378
            'xxxview' => $xxxview,
379
            'consoleview' => $consoleview,
380
            'bookview' => $bookview,
381
            'style' => $style,
382
            'rate_limit' => $rateLimit ? $rateLimit['rate_limit'] : 60,
383
        ];
384
385
        if (! empty($email)) {
386
            $email = trim($email);
387
            $sql += ['email' => $email];
388
        }
389
390
        $user = self::find($id);
391
        $user->update($sql);
392
        $user->syncRoles([$rateLimit['name']]);
393
394
        return self::SUCCESS;
395
    }
396
397
    /**
398
     * @return User|Builder|Model|object|null
399
     */
400
    public static function getByUsername(string $userName)
401
    {
402
        return self::whereUsername($userName)->first();
403
    }
404
405
    /**
406
     * @return Model|static
407
     *
408
     * @throws ModelNotFoundException
409
     */
410
    public static function getByEmail(string $email)
411
    {
412
        return self::whereEmail($email)->first();
413
    }
414
415
    public static function updateUserRole(int $uid, int|string $role, bool $applyPromotions = true, bool $stackRole = true, ?int $changedBy = null, ?string $originalExpiryBeforeEdits = null): bool
416
    {
417
        \Log::info('updateUserRole called', [
418
            'uid' => $uid,
419
            'role' => $role,
420
            'role_type' => gettype($role),
421
            'applyPromotions' => $applyPromotions,
422
            'stackRole' => $stackRole,
423
            'changedBy' => $changedBy,
424
            'originalExpiryBeforeEdits' => $originalExpiryBeforeEdits
425
        ]);
426
427
        // Handle role parameter - can be int, numeric string, or role name
428
        if (is_numeric($role)) {
429
            // It's a number (either int or numeric string)
430
            $roleQuery = Role::query()->where('id', (int) $role)->first();
431
        } else {
432
            // It's a role name
433
            $roleQuery = Role::query()->where('name', $role)->first();
434
        }
435
436
        if (!$roleQuery) {
437
            \Log::error('Role not found', ['role' => $role, 'role_type' => gettype($role)]);
438
            return false;
439
        }
440
441
        \Log::info('Role found', ['role_id' => $roleQuery->id, 'role_name' => $roleQuery->name]);
442
443
        $roleName = $roleQuery->name;
444
        $user = self::find($uid);
445
446
        if (!$user) {
447
            \Log::error('User not found', ['uid' => $uid]);
448
            return false;
449
        }
450
451
        $currentRoleId = $user->roles_id;
452
        // Use the original expiry from before any edits if provided, otherwise use current
453
        $oldExpiryDate = $originalExpiryBeforeEdits
454
            ? Carbon::parse($originalExpiryBeforeEdits)
455
            : ($user->rolechangedate ? Carbon::parse($user->rolechangedate) : null);
456
457
        // The current expiry (after any updates in controller) is what we use for stacking logic
458
        $currentExpiryDate = $user->rolechangedate ? Carbon::parse($user->rolechangedate) : null;
459
460
        \Log::info('User current state', [
461
            'currentRoleId' => $currentRoleId,
462
            'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
463
            'currentExpiryDate' => $currentExpiryDate?->toDateTimeString(),
464
            'oldExpiryIsFuture' => $oldExpiryDate?->isFuture(),
465
            'currentExpiryIsFuture' => $currentExpiryDate?->isFuture()
466
        ]);
467
468
        // Check if role is actually changing
469
        if ($currentRoleId === $roleQuery->id) {
470
            \Log::info('Role not changing, returning');
471
            return true; // No change needed
472
        }
473
474
        // Determine if we should stack this role change (based on CURRENT expiry, not old)
475
        $shouldStack = $stackRole && $currentExpiryDate && $currentExpiryDate->isFuture();
476
477
        \Log::info('Stack decision', [
478
            'shouldStack' => $shouldStack,
479
            'stackRole' => $stackRole,
480
            'hasCurrentExpiry' => $currentExpiryDate !== null,
481
            'isFuture' => $currentExpiryDate?->isFuture()
482
        ]);
483
484
        if ($shouldStack) {
485
            \Log::info('Stacking role change', [
486
                'oldExpiryDate' => $oldExpiryDate->toDateTimeString(),
0 ignored issues
show
Bug introduced by
The method toDateTimeString() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

486
                'oldExpiryDate' => $oldExpiryDate->/** @scrutinizer ignore-call */ toDateTimeString(),

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
487
                'currentExpiryDate' => $currentExpiryDate->toDateTimeString(),
488
                'pendingRoleStartDate' => $oldExpiryDate->toDateTimeString()
489
            ]);
490
491
            // Calculate new expiry date for the pending role
492
            // Start with the role's base duration (addyears field converted to days)
493
            $baseDays = $roleQuery->addyears * 365;
0 ignored issues
show
Bug introduced by
The property addyears does not seem to exist on Spatie\Permission\Models\Role.
Loading history...
494
            $promotionDays = 0;
495
496
            if ($applyPromotions) {
497
                $promotionDays = RolePromotion::calculateAdditionalDays($roleQuery->id);
498
            }
499
500
            $totalDays = $baseDays + $promotionDays;
501
502
            // New role will start at oldExpiryDate (original expiry date before any admin edits)
503
            // Then add the total days (base + promotion) to calculate when it will expire
504
            $newExpiryDate = $oldExpiryDate->copy()->addDays($totalDays);
505
506
            \Log::info('Calculated new expiry for pending role', [
507
                'pendingRoleStartDate' => $oldExpiryDate->toDateTimeString(),
508
                'baseDays' => $baseDays,
509
                'promotionDays' => $promotionDays,
510
                'totalDays' => $totalDays,
511
                'newExpiryDate' => $newExpiryDate->toDateTimeString()
512
            ]);
513
514
            // Stack the role change - set it as pending
515
            // Use oldExpiryDate (original expiry) for when the role will start
516
            $user->update([
517
                'pending_roles_id' => $roleQuery->id,
518
                'pending_role_start_date' => $oldExpiryDate,
519
            ]);
520
521
            \Log::info('Pending role updated', [
522
                'pending_roles_id' => $roleQuery->id,
523
                'pending_role_start_date' => $oldExpiryDate->toDateTimeString(),
524
                'pending_role_start_date_formatted' => $oldExpiryDate->format('Y-m-d H:i:s')
525
            ]);
526
527
            // Record in history as a stacked change
528
            // effective_date should match old_expiry_date (when the stacked role will start)
529
            try {
530
                $history = UserRoleHistory::recordRoleChange(
531
                    userId: $user->id,
532
                    oldRoleId: $currentRoleId,
533
                    newRoleId: $roleQuery->id,
534
                    oldExpiryDate: $oldExpiryDate, // When current role expires
535
                    newExpiryDate: $newExpiryDate, // When the NEW role will expire (calculated from oldExpiryDate)
536
                    effectiveDate: $oldExpiryDate, // Same as old_expiry_date - when the new role starts
0 ignored issues
show
Bug introduced by
It seems like $oldExpiryDate can also be of type null; however, parameter $effectiveDate of App\Models\UserRoleHistory::recordRoleChange() does only seem to accept Illuminate\Support\Carbon, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

536
                    /** @scrutinizer ignore-type */ effectiveDate: $oldExpiryDate, // Same as old_expiry_date - when the new role starts
Loading history...
537
                    isStacked: true,
538
                    changeReason: 'stacked_role_change',
539
                    changedBy: $changedBy
540
                );
541
542
                \Log::info('Role history recorded for stacked change', [
543
                    'history_id' => $history->id,
544
                    'effective_date' => $history->effective_date->toDateTimeString(),
545
                    'old_expiry_date' => $history->old_expiry_date?->toDateTimeString(),
546
                    'new_expiry_date' => $history->new_expiry_date?->toDateTimeString(),
547
                    'note' => 'effective_date equals old_expiry_date (when stacked role starts)'
548
                ]);
549
            } catch (\Exception $e) {
550
                \Log::error('Failed to record role history', [
551
                    'error' => $e->getMessage(),
552
                    'trace' => $e->getTraceAsString()
553
                ]);
554
            }
555
556
            return true;
557
        }
558
559
        // Apply the role change immediately
560
        // Calculate base days from role's addyears field
561
        $baseDays = $roleQuery->addyears * 365;
562
        $promotionDays = 0;
563
564
        if ($applyPromotions) {
565
            $promotionDays = RolePromotion::calculateAdditionalDays($roleQuery->id);
566
        }
567
568
        $totalDays = $baseDays + $promotionDays;
569
570
        \Log::info('Applying immediate role change', [
571
            'baseDays' => $baseDays,
572
            'promotionDays' => $promotionDays,
573
            'totalDays' => $totalDays,
574
            'applyPromotions' => $applyPromotions
575
        ]);
576
577
        // Calculate new expiry date
578
        $newExpiryDate = null;
579
        if ($totalDays > 0) {
580
            // For immediate changes, start from now (not from old expiry)
581
            $baseDate = Carbon::now();
582
            $newExpiryDate = $baseDate->copy()->addDays($totalDays);
583
584
            \Log::info('Calculated new expiry date', [
585
                'baseDate' => $baseDate->toDateTimeString(),
586
                'totalDays' => $totalDays,
587
                'newExpiryDate' => $newExpiryDate->toDateTimeString()
588
            ]);
589
        }
590
591
        // Update the user's role
592
        $updated = $user->update([
593
            'roles_id' => $roleQuery->id,
594
            'rolechangedate' => $newExpiryDate,
595
        ]);
596
597
        \Log::info('User role updated', [
598
            'updated' => $updated,
599
            'new_roles_id' => $roleQuery->id,
600
            'new_rolechangedate' => $newExpiryDate?->toDateTimeString()
601
        ]);
602
603
        // Sync Spatie roles
604
        $user->syncRoles([$roleName]);
605
606
        // Record in history
607
        if ($updated) {
608
            try {
609
                $history = UserRoleHistory::recordRoleChange(
610
                    userId: $user->id,
611
                    oldRoleId: $currentRoleId,
612
                    newRoleId: $roleQuery->id,
613
                    oldExpiryDate: $oldExpiryDate,
614
                    newExpiryDate: $newExpiryDate,
615
                    effectiveDate: Carbon::now(),
616
                    isStacked: false,
617
                    changeReason: 'immediate_role_change',
618
                    changedBy: $changedBy
619
                );
620
621
                \Log::info('Role history recorded for immediate change', [
622
                    'history_id' => $history->id,
623
                    'effective_date' => $history->effective_date->toDateTimeString(),
624
                    'old_expiry_date' => $history->old_expiry_date?->toDateTimeString(),
625
                    'new_expiry_date' => $history->new_expiry_date?->toDateTimeString()
626
                ]);
627
            } catch (\Exception $e) {
628
                \Log::error('Failed to record role history for immediate change', [
629
                    'error' => $e->getMessage(),
630
                    'trace' => $e->getTraceAsString()
631
                ]);
632
            }
633
634
            // Track promotion statistics if applicable
635
            if ($applyPromotions && $promotionDays > 0) {
636
                $promotions = RolePromotion::getActivePromotions($roleQuery->id);
637
                foreach ($promotions as $promotion) {
638
                    $promotion->trackApplication(
639
                        $user->id,
640
                        $roleQuery->id,
641
                        $oldExpiryDate,
642
                        $newExpiryDate
643
                    );
644
                }
645
            }
646
        }
647
648
        return $updated;
649
    }
650
    public static function updateExpiredRoles(): void
651
    {
652
        $now = CarbonImmutable::now();
653
        $period = [
654
            'day' => $now->addDay(),
655
            'week' => $now->addWeek(),
656
            'month' => $now->addMonth(),
657
        ];
658
659
        foreach ($period as $value) {
660
            $users = self::query()->whereDate('rolechangedate', '=', $value)->get();
661
            $days = $now->diffInDays($value, true);
662
            foreach ($users as $user) {
663
                SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails');
664
            }
665
        }
666
667
        // Process expired roles
668
        foreach (self::query()->whereDate('rolechangedate', '<', $now)->get() as $expired) {
669
            $oldRoleId = $expired->roles_id;
670
            $oldExpiryDate = $expired->rolechangedate ? Carbon::parse($expired->rolechangedate) : null;
671
672
            // Check if there's a pending stacked role
673
            if ($expired->pending_roles_id && $expired->pending_role_start_date) {
674
                $pendingStartDate = Carbon::parse($expired->pending_role_start_date);
675
676
                // If the pending role should start now or earlier
677
                if ($pendingStartDate->lte($now)) {
678
                    // Apply the pending role
679
                    $newRoleId = $expired->pending_roles_id;
680
                    $roleQuery = Role::query()->where('id', $newRoleId)->first();
681
682
                    if ($roleQuery) {
683
                        // Calculate base days from role's addyears field
684
                        $baseDays = $roleQuery->addyears * 365;
0 ignored issues
show
Bug introduced by
The property addyears does not seem to exist on Spatie\Permission\Models\Role.
Loading history...
685
                        $promotionDays = RolePromotion::calculateAdditionalDays($newRoleId);
686
                        $totalDays = $baseDays + $promotionDays;
687
688
                        // Calculate new expiry date from now (when the role is being activated)
689
                        $newExpiryDate = $totalDays > 0 ? $now->addDays($totalDays) : null;
690
691
                        // Update user with pending role
692
                        $expired->update([
693
                            'roles_id' => $newRoleId,
694
                            'rolechangedate' => $newExpiryDate,
695
                            'pending_roles_id' => null,
696
                            'pending_role_start_date' => null,
697
                        ]);
698
                        $expired->syncRoles([$roleQuery->name]);
699
700
                        // Record in history
701
                        UserRoleHistory::recordRoleChange(
702
                            userId: $expired->id,
703
                            oldRoleId: $oldRoleId,
704
                            newRoleId: $newRoleId,
705
                            oldExpiryDate: $oldExpiryDate,
706
                            newExpiryDate: $newExpiryDate,
707
                            effectiveDate: Carbon::instance($now),
708
                            isStacked: true,
709
                            changeReason: 'stacked_role_activated',
710
                            changedBy: null
711
                        );
712
713
                        continue; // Skip the default expiry handling
714
                    }
715
                }
716
            }
717
718
            // Default behavior: downgrade to User role
719
            $expired->update([
720
                'roles_id' => self::ROLE_USER,
721
                'rolechangedate' => null,
722
                'pending_roles_id' => null,
723
                'pending_role_start_date' => null,
724
            ]);
725
            $expired->syncRoles(['User']);
726
727
            // Record in history
728
            UserRoleHistory::recordRoleChange(
729
                userId: $expired->id,
730
                oldRoleId: $oldRoleId,
731
                newRoleId: self::ROLE_USER,
732
                oldExpiryDate: $oldExpiryDate,
733
                newExpiryDate: null,
734
                effectiveDate: Carbon::instance($now),
735
                isStacked: false,
736
                changeReason: 'role_expired',
737
                changedBy: null
738
            );
739
740
            SendAccountExpiredEmail::dispatch($expired)->onQueue('emails');
741
        }
742
    }
743
744
    /**
745
     * @throws \Throwable
746
     */
747
    public static function getRange($start, $offset, $orderBy, ?string $userName = '', ?string $email = '', ?string $host = '', ?string $role = '', bool $apiRequests = false, ?string $createdFrom = '', ?string $createdTo = ''): Collection
748
    {
749
        if ($apiRequests) {
750
            UserRequest::clearApiRequests(false);
751
            $query = "
752
				SELECT users.*, roles.name AS rolename, COUNT(user_requests.id) AS apirequests
753
				FROM users
754
				INNER JOIN roles ON roles.id = users.roles_id
755
				LEFT JOIN user_requests ON user_requests.users_id = users.id
756
				WHERE users.id != 0 %s %s %s %s %s %s
757
				AND email != '[email protected]'
758
				GROUP BY users.id
759
				ORDER BY %s %s %s ";
760
        } else {
761
            $query = '
762
				SELECT users.*, roles.name AS rolename
763
				FROM users
764
				INNER JOIN roles ON roles.id = users.roles_id
765
				WHERE 1=1 %s %s %s %s %s %s
766
				ORDER BY %s %s %s';
767
        }
768
        $order = self::getBrowseOrder($orderBy);
769
770
        return self::fromQuery(
771
            sprintf(
772
                $query,
773
                ! empty($userName) ? 'AND users.username '.'LIKE '.escapeString('%'.$userName.'%') : '',
774
                ! empty($email) ? 'AND users.email '.'LIKE '.escapeString('%'.$email.'%') : '',
775
                ! empty($host) ? 'AND users.host '.'LIKE '.escapeString('%'.$host.'%') : '',
776
                (! empty($role) ? ('AND users.roles_id = '.$role) : ''),
777
                ! empty($createdFrom) ? 'AND users.created_at >= '.escapeString($createdFrom.' 00:00:00') : '',
778
                ! empty($createdTo) ? 'AND users.created_at <= '.escapeString($createdTo.' 23:59:59') : '',
779
                $order[0],
780
                $order[1],
781
                ($start === false ? '' : ('LIMIT '.$offset.' OFFSET '.$start))
782
            )
783
        );
784
    }
785
786
    /**
787
     * Get sort types for sorting users on the web page user list.
788
     *
789
     * @return string[]
790
     */
791
    public static function getBrowseOrder($orderBy): array
792
    {
793
        $order = (empty($orderBy) ? 'username_desc' : $orderBy);
794
        $orderArr = explode('_', $order);
795
        $orderField = match ($orderArr[0]) {
796
            'email' => 'email',
797
            'host' => 'host',
798
            'createdat' => 'created_at',
799
            'lastlogin' => 'lastlogin',
800
            'apiaccess' => 'apiaccess',
801
            'grabs' => 'grabs',
802
            'role' => 'rolename',
803
            'rolechangedate' => 'rolechangedate',
804
            'verification' => 'verified',
805
            default => 'username',
806
        };
807
        $orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc';
808
809
        return [$orderField, $orderSort];
810
    }
811
812
    /**
813
     * Verify a password against a hash.
814
     *
815
     * Automatically update the hash if it needs to be.
816
     *
817
     * @param  string  $password  Password to check against hash.
818
     * @param  bool|string  $hash  Hash to check against password.
819
     * @param  int  $userID  ID of the user.
820
     */
821
    public static function checkPassword(string $password, bool|string $hash, int $userID = -1): bool
822
    {
823
        if (Hash::check($password, $hash) === false) {
824
            return false;
825
        }
826
827
        // Update the hash if it needs to be.
828
        if (is_numeric($userID) && $userID > 0 && Hash::needsRehash($hash)) {
829
            $hash = self::hashPassword($password);
830
831
            if ($hash !== false) {
0 ignored issues
show
introduced by
The condition $hash !== false is always true.
Loading history...
832
                self::find($userID)->update(['password' => $hash]);
833
            }
834
        }
835
836
        return true;
837
    }
838
839
    public static function updateRssKey($uid): int
840
    {
841
        self::find($uid)->update(['api_token' => md5(Password::getRepository()->createNewToken())]);
0 ignored issues
show
Bug introduced by
The method createNewToken() does not exist on Illuminate\Auth\Passwords\TokenRepositoryInterface. Did you maybe mean create()? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

841
        self::find($uid)->update(['api_token' => md5(Password::getRepository()->/** @scrutinizer ignore-call */ createNewToken())]);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
842
843
        return self::SUCCESS;
844
    }
845
846
    public static function updatePassResetGuid($id, $guid): int
847
    {
848
        self::find($id)->update(['resetguid' => $guid]);
849
850
        return self::SUCCESS;
851
    }
852
853
    public static function updatePassword(int $id, string $password): int
854
    {
855
        self::find($id)->update(['password' => self::hashPassword($password)]);
856
857
        return self::SUCCESS;
858
    }
859
860
    public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int
861
    {
862
        self::find($id)->update(['rolechangedate' => $roleChangeDate]);
863
864
        return self::SUCCESS;
865
    }
866
867
    public static function hashPassword($password): string
868
    {
869
        return Hash::make($password);
870
    }
871
872
    /**
873
     * @return Model|static
874
     *
875
     * @throws ModelNotFoundException
876
     */
877
    public static function getByPassResetGuid(string $guid)
878
    {
879
        return self::whereResetguid($guid)->first();
880
    }
881
882
    public static function incrementGrabs(int $id, int $num = 1): void
883
    {
884
        self::find($id)->increment('grabs', $num);
885
    }
886
887
    /**
888
     * @return Model|null|static
889
     */
890
    public static function getByRssToken(string $rssToken)
891
    {
892
        return self::whereApiToken($rssToken)->first();
893
    }
894
895
    public static function isValidUrl($url): bool
896
    {
897
        return (! preg_match('/^(http|https|ftp):\/\/([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+):?(\d+)?\/?/i', $url)) ? false : true;
898
    }
899
900
    /**
901
     * @throws \Exception
902
     */
903
    public static function generatePassword(int $length = 15): string
904
    {
905
        return Str::password($length);
906
    }
907
908
    /**
909
     * @throws \Exception
910
     */
911
    public static function signUp($userName, $password, $email, $host, $notes, int $invites = Invitation::DEFAULT_INVITES, string $inviteCode = '', bool $forceInviteMode = false, int $role = self::ROLE_USER, bool $validate = true): bool|int|string
912
    {
913
        $user = [
914
            'username' => trim($userName),
915
            'password' => trim($password),
916
            'email' => trim($email),
917
        ];
918
919
        if ($validate) {
920
            $validator = Validator::make($user, [
921
                'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
922
                'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new ValidEmailDomain()],
923
                'password' => ['required', 'string', 'min:8', 'confirmed', 'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/'],
924
            ]);
925
926
            if ($validator->fails()) {
927
                return implode('', Arr::collapse($validator->errors()->toArray()));
928
            }
929
        }
930
931
        // Make sure this is the last check, as if a further validation check failed, the invite would still have been used up.
932
        $invitedBy = 0;
933
        if (! $forceInviteMode && (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) {
934
            if ($inviteCode === '') {
935
                return self::ERR_SIGNUP_BADINVITECODE;
936
            }
937
938
            $invitedBy = self::checkAndUseInvite($inviteCode);
939
            if ($invitedBy < 0) {
940
                return self::ERR_SIGNUP_BADINVITECODE;
941
            }
942
        }
943
944
        return self::add($user['username'], $user['password'], $user['email'], $role, $notes, $host, $invites, $invitedBy);
945
    }
946
947
    /**
948
     * If a invite is used, decrement the person who invited's invite count.
949
     */
950
    public static function checkAndUseInvite(string $inviteCode): int
951
    {
952
        $invite = Invitation::findValidByToken($inviteCode);
953
        if (! $invite) {
954
            return -1;
955
        }
956
957
        self::query()->where('id', $invite->invited_by)->decrement('invites');
958
        $invite->markAsUsed(0); // Will be updated with actual user ID later
959
960
        return $invite->invited_by;
961
    }
962
963
    /**
964
     * @return false|int|mixed
965
     */
966
    public static function add(string $userName, string $password, string $email, int $role, ?string $notes = '', string $host = '', int $invites = Invitation::DEFAULT_INVITES, int $invitedBy = 0)
967
    {
968
        $password = self::hashPassword($password);
969
        if (! $password) {
970
            return false;
971
        }
972
973
        $storeips = config('nntmux:settings.store_user_ip') === true ? $host : '';
974
975
        $user = self::create(
976
            [
977
                'username' => $userName,
978
                'password' => $password,
979
                'email' => $email,
980
                'host' => $storeips,
981
                'roles_id' => $role,
982
                'invites' => $invites,
983
                'invitedby' => (int) $invitedBy === 0 ? null : $invitedBy,
984
                'notes' => $notes,
985
            ]
986
        );
987
988
        return $user->id;
989
    }
990
991
    /**
992
     * Get the list of categories the user has excluded.
993
     *
994
     * @param  int  $userID  ID of the user.
995
     *
996
     * @throws \Exception
997
     */
998
    public static function getCategoryExclusionById(int $userID): array
999
    {
1000
        $ret = [];
1001
1002
        $user = self::find($userID);
1003
1004
        $userAllowed = $user->getDirectPermissions()->pluck('name')->toArray();
1005
        $roleAllowed = $user->getAllPermissions()->pluck('name')->toArray();
1006
1007
        $allowed = array_intersect($roleAllowed, $userAllowed);
1008
1009
        $cats = ['view console', 'view movies', 'view audio', 'view tv', 'view pc', 'view adult', 'view books', 'view other'];
1010
1011
        if (! empty($allowed)) {
1012
            foreach ($cats as $cat) {
1013
                if (! \in_array($cat, $allowed, false)) {
1014
                    $ret[] = match ($cat) {
1015
                        'view console' => 1000,
1016
                        'view movies' => 2000,
1017
                        'view audio' => 3000,
1018
                        'view pc' => 4000,
1019
                        'view tv' => 5000,
1020
                        'view adult' => 6000,
1021
                        'view books' => 7000,
1022
                        'view other' => 1,
1023
                    };
1024
                }
1025
            }
1026
        }
1027
1028
        return Category::query()->whereIn('root_categories_id', $ret)->pluck('id')->toArray();
1029
    }
1030
1031
    /**
1032
     * @throws \Exception
1033
     */
1034
    public static function getCategoryExclusionForApi(Request $request): array
1035
    {
1036
        $apiToken = $request->has('api_token') ? $request->input('api_token') : $request->input('apikey');
1037
        $user = self::getByRssToken($apiToken);
1038
1039
        return self::getCategoryExclusionById($user->id);
1040
    }
1041
1042
    /**
1043
     * @throws \Exception
1044
     */
1045
    public static function sendInvite($serverUrl, $uid, $emailTo): string
1046
    {
1047
        $user = self::find($uid);
1048
1049
        // Create invitation using our custom system
1050
        $invitation = Invitation::createInvitation($emailTo, $user->id);
1051
        $url = $serverUrl.'/register?token='.$invitation->token;
1052
1053
        // Send invitation email
1054
        $invitationService = app(InvitationService::class);
1055
        $invitationService->sendInvitationEmail($invitation);
1056
1057
        return $url;
1058
    }
1059
1060
    /**
1061
     * Deletes users that have not verified their accounts for 3 or more days.
1062
     */
1063
    public static function deleteUnVerified(): void
1064
    {
1065
        static::whereVerified(0)->where('created_at', '<', now()->subDays(3))->delete();
1066
    }
1067
1068
    public function passwordSecurity(): HasOne
1069
    {
1070
        return $this->hasOne(PasswordSecurity::class);
1071
    }
1072
1073
    public static function canPost($user_id): bool
1074
    {
1075
        // return true if can_post column is true and false if can_post column is false
1076
        return self::where('id', $user_id)->value('can_post');
1077
    }
1078
}
1079