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

User::add()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 14
c 0
b 0
f 0
dl 0
loc 23
rs 9.7998
cc 4
nc 3
nop 8

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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