mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
1870 lines
57 KiB
PHP
1870 lines
57 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Enums\SignupError;
|
|
use App\Enums\UserRole;
|
|
use App\Jobs\SendAccountExpiredEmail;
|
|
use App\Jobs\SendAccountWillExpireEmail;
|
|
use App\Notifications\VerifyEmailBranded;
|
|
use App\Rules\ValidEmailDomain;
|
|
use App\Services\InvitationService;
|
|
use Carbon\CarbonImmutable;
|
|
use Illuminate\Auth\MustVerifyEmail as MustVerifyEmailTrait;
|
|
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
|
|
use Illuminate\Contracts\Auth\MustVerifyEmail as MustVerifyEmailContract;
|
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Notifications\Notifiable;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Validator;
|
|
use Illuminate\Support\Str;
|
|
use Spatie\LaravelPasskeys\Models\Concerns\HasPasskeys;
|
|
use Spatie\LaravelPasskeys\Models\Concerns\InteractsWithPasskeys;
|
|
use Spatie\LaravelPasskeys\Models\Passkey;
|
|
use Spatie\Permission\Models\Role;
|
|
use Spatie\Permission\Traits\HasRoles;
|
|
|
|
/**
|
|
* @property int $id
|
|
* @property string $username
|
|
* @property string|null $firstname
|
|
* @property string|null $lastname
|
|
* @property string $email
|
|
* @property string $password
|
|
* @property int $roles_id
|
|
* @property string|null $host
|
|
* @property int $grabs
|
|
* @property string $api_token
|
|
* @property Carbon|null $created_at
|
|
* @property Carbon|null $updated_at
|
|
* @property string|null $resetguid
|
|
* @property Carbon|null $lastlogin
|
|
* @property Carbon|null $apiaccess
|
|
* @property Carbon|null $lastdownload
|
|
* @property int $invites
|
|
* @property int|null $invitedby
|
|
* @property bool $movieview
|
|
* @property bool $xxxview
|
|
* @property bool $musicview
|
|
* @property bool $consoleview
|
|
* @property bool $bookview
|
|
* @property bool $gameview
|
|
* @property string|null $saburl
|
|
* @property string|null $sabapikey
|
|
* @property bool|null $sabapikeytype
|
|
* @property bool|null $sabpriority
|
|
* @property string|null $nzbgeturl
|
|
* @property string|null $nzbgetusername
|
|
* @property string|null $nzbgetpassword
|
|
* @property string|null $nzbvortex_api_key
|
|
* @property string|null $nzbvortex_server_url
|
|
* @property string $notes
|
|
* @property string|null $cp_url
|
|
* @property string|null $cp_api
|
|
* @property string|null $style
|
|
* @property Carbon|null $rolechangedate
|
|
* @property Carbon|null $pending_role_start_date
|
|
* @property int|null $pending_roles_id
|
|
* @property string|null $remember_token
|
|
* @property string|null $session_token
|
|
* @property int $rate_limit
|
|
* @property Carbon|null $email_verified_at
|
|
* @property bool $verified
|
|
* @property string|null $verification_token
|
|
* @property string|null $timezone
|
|
* @property bool $can_post
|
|
* @property array<int>|string|null $categoryexclusions Computed from join/subquery for category exclusion lists
|
|
* @property string|null $sort_date Computed column for date sorting
|
|
* @property string|null $role_name Computed from join with roles table
|
|
* @property int|null $num Computed count value
|
|
* @property string|null $mth Computed month value
|
|
* @property-read bool $is_disabled Computed via Attribute accessor
|
|
* @property bool|null $is_role_expired Computed via Attribute accessor
|
|
* @property int|null $days_until_expiry Computed via Attribute accessor
|
|
* @property int|null $count Computed count from aggregate queries
|
|
* @property int|null $daily_api_count Computed daily API request count
|
|
* @property int|null $daily_download_count Computed daily download count
|
|
* @property-read Collection<int, ReleaseComment> $comments
|
|
* @property-read Collection<int, UserDownload> $downloads
|
|
* @property-read Collection<int, DnzbFailure> $failedReleases
|
|
* @property-read Collection<int, Invitation> $invitations
|
|
* @property-read Collection<int, UsersRelease> $releases
|
|
* @property-read Collection<int, UserRequest> $requests
|
|
* @property-read Collection<int, UserSerie> $series
|
|
* @property-read Collection<int, RolePromotionStat> $promotionStats
|
|
* @property-read Collection<int, UserRoleHistory> $roleHistory
|
|
* @property-read Collection<int, Passkey> $passkeys
|
|
* @property-read Role|null $role
|
|
* @property-read Role|null $pendingRole
|
|
* @property-read PasswordSecurity|null $passwordSecurity
|
|
*
|
|
* @method static Builder|User whereUsername(string $value)
|
|
* @method static Builder|User whereEmail(string $value)
|
|
* @method static Builder|User whereApiToken(string $value)
|
|
* @method static Builder|User whereResetguid(string $value)
|
|
* @method static Builder|User active()
|
|
* @method static Builder|User verified()
|
|
* @method static Builder|User withRole(int|string $role)
|
|
* @method static Builder|User expiringSoon(int $days = 7)
|
|
* @method static Builder|User expired()
|
|
*/
|
|
final class User extends Authenticatable implements CanResetPasswordContract, HasPasskeys, MustVerifyEmailContract
|
|
{
|
|
use HasFactory; // @phpstan-ignore missingType.generics
|
|
use HasRoles;
|
|
use InteractsWithPasskeys;
|
|
use MustVerifyEmailTrait;
|
|
use Notifiable;
|
|
use SoftDeletes;
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
protected $hidden = [
|
|
'remember_token',
|
|
'session_token',
|
|
'password',
|
|
];
|
|
|
|
/**
|
|
* @var list<string>
|
|
*/
|
|
protected $guarded = [];
|
|
|
|
/**
|
|
* Roles excluded from promotions.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const PROMOTION_EXCLUDED_ROLES = [
|
|
'User',
|
|
'Admin',
|
|
'Moderator',
|
|
'Disabled',
|
|
'Friend',
|
|
];
|
|
|
|
/**
|
|
* Roles eligible for automatic unverified-account cleanup.
|
|
*
|
|
* @var list<string>
|
|
*/
|
|
private const UNVERIFIED_CLEANUP_ROLES = [
|
|
'User',
|
|
'FreeUser',
|
|
];
|
|
|
|
/**
|
|
* Days in a year for subscription calculations.
|
|
*/
|
|
private const DAYS_PER_YEAR = 365;
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'email_verified_at' => 'datetime',
|
|
'lastlogin' => 'datetime',
|
|
'apiaccess' => 'datetime',
|
|
'lastdownload' => 'datetime',
|
|
'rolechangedate' => 'datetime',
|
|
'pending_role_start_date' => 'datetime',
|
|
'created_at' => 'datetime',
|
|
'updated_at' => 'datetime',
|
|
'password' => 'hashed',
|
|
'movieview' => 'boolean',
|
|
'xxxview' => 'boolean',
|
|
'musicview' => 'boolean',
|
|
'consoleview' => 'boolean',
|
|
'bookview' => 'boolean',
|
|
'gameview' => 'boolean',
|
|
'sabapikeytype' => 'boolean',
|
|
'sabpriority' => 'boolean',
|
|
'verified' => 'boolean',
|
|
'can_post' => 'boolean',
|
|
'grabs' => 'integer',
|
|
'invites' => 'integer',
|
|
'rate_limit' => 'integer',
|
|
'roles_id' => 'integer',
|
|
'pending_roles_id' => 'integer',
|
|
'invitedby' => 'integer',
|
|
];
|
|
}
|
|
|
|
public function getPasskeyName(): string
|
|
{
|
|
return $this->email ?: $this->username;
|
|
}
|
|
|
|
public function getPasskeyId(): string
|
|
{
|
|
return (string) $this->id;
|
|
}
|
|
|
|
public function getPasskeyDisplayName(): string
|
|
{
|
|
return $this->full_name ?: $this->username;
|
|
}
|
|
|
|
protected function getDefaultGuardName(): string
|
|
{
|
|
return 'web';
|
|
}
|
|
|
|
// ===== Relationships =====
|
|
|
|
/**
|
|
* @return BelongsTo<Role, $this>
|
|
*/
|
|
public function role(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Role::class, 'roles_id');
|
|
}
|
|
|
|
/**
|
|
* @return BelongsTo<Role, $this>
|
|
*/
|
|
public function pendingRole(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Role::class, 'pending_roles_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UserRequest, $this>
|
|
*/
|
|
public function requests(): HasMany
|
|
{
|
|
return $this->hasMany(UserRequest::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UserDownload, $this>
|
|
*/
|
|
public function downloads(): HasMany
|
|
{
|
|
return $this->hasMany(UserDownload::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UsersRelease, $this>
|
|
*/
|
|
public function releases(): HasMany
|
|
{
|
|
return $this->hasMany(UsersRelease::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UserSerie, $this>
|
|
*/
|
|
public function series(): HasMany
|
|
{
|
|
return $this->hasMany(UserSerie::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<Invitation, $this>
|
|
*/
|
|
public function invitations(): HasMany
|
|
{
|
|
return $this->hasMany(Invitation::class, 'invited_by');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<DnzbFailure, $this>
|
|
*/
|
|
public function failedReleases(): HasMany
|
|
{
|
|
return $this->hasMany(DnzbFailure::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<ReleaseComment, $this>
|
|
*/
|
|
public function comments(): HasMany
|
|
{
|
|
return $this->hasMany(ReleaseComment::class, 'users_id');
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<RolePromotionStat, $this>
|
|
*/
|
|
public function promotionStats(): HasMany
|
|
{
|
|
return $this->hasMany(RolePromotionStat::class);
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UserRoleHistory, $this>
|
|
*/
|
|
public function roleHistory(): HasMany
|
|
{
|
|
return $this->hasMany(UserRoleHistory::class);
|
|
}
|
|
|
|
/**
|
|
* @return HasOne<PasswordSecurity, $this>
|
|
*/
|
|
public function passwordSecurity(): HasOne
|
|
{
|
|
return $this->hasOne(PasswordSecurity::class);
|
|
}
|
|
|
|
/**
|
|
* @return HasMany<UserExcludedCategory, $this>
|
|
*/
|
|
public function excludedCategories(): HasMany
|
|
{
|
|
return $this->hasMany(UserExcludedCategory::class, 'users_id');
|
|
}
|
|
|
|
// ===== Backward Compatibility Aliases =====
|
|
|
|
public function request(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->requests();
|
|
}
|
|
|
|
public function download(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->downloads();
|
|
}
|
|
|
|
public function release(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->releases();
|
|
}
|
|
|
|
public function invitation(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->invitations();
|
|
}
|
|
|
|
public function failedRelease(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->failedReleases();
|
|
}
|
|
|
|
public function comment(): HasMany // @phpstan-ignore missingType.generics
|
|
{
|
|
return $this->comments();
|
|
}
|
|
|
|
// ===== Query Scopes =====
|
|
|
|
/**
|
|
* Scope to get active (non-disabled) users.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeActive(Builder $query): Builder
|
|
{
|
|
return $query->where('roles_id', '!=', UserRole::DISABLED->value);
|
|
}
|
|
|
|
/**
|
|
* Scope to get verified users.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeVerified(Builder $query): Builder
|
|
{
|
|
return $query->where(function (Builder $verifiedQuery): void {
|
|
$verifiedQuery
|
|
->where('verified', true)
|
|
->orWhereNotNull('email_verified_at');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Determine if the user has verified their email address.
|
|
*/
|
|
public function hasVerifiedEmail(): bool
|
|
{
|
|
return $this->verified || $this->email_verified_at !== null;
|
|
}
|
|
|
|
/**
|
|
* Mark the user's email as verified and keep legacy columns in sync.
|
|
*/
|
|
public function markEmailAsVerified(): bool
|
|
{
|
|
return $this->forceFill([
|
|
'email_verified_at' => now(),
|
|
'verified' => true,
|
|
'verification_token' => null,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* Reset email verification state, typically after an email change.
|
|
*/
|
|
public function resetEmailVerification(): bool
|
|
{
|
|
return $this->forceFill([
|
|
'email_verified_at' => null,
|
|
'verified' => false,
|
|
'verification_token' => null,
|
|
])->save();
|
|
}
|
|
|
|
/**
|
|
* Send the branded email verification notification.
|
|
*
|
|
* Uses {@see VerifyEmailBranded} so the verification email renders with
|
|
* the same themed Markdown components as the rest of the site's
|
|
* transactional emails (instead of Laravel's default notification look).
|
|
*/
|
|
public function sendEmailVerificationNotification(): void
|
|
{
|
|
$this->notify(new VerifyEmailBranded);
|
|
}
|
|
|
|
/**
|
|
* Scope to filter users by role.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeWithRole(Builder $query, int|string $role): Builder
|
|
{
|
|
if (is_numeric($role)) {
|
|
return $query->where('roles_id', (int) $role);
|
|
}
|
|
|
|
return $query->whereHas('role', fn (Builder $q) => $q->where('name', $role));
|
|
}
|
|
|
|
/**
|
|
* Scope to get users with roles expiring within specified days.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeExpiringSoon(Builder $query, int $days = 7): Builder
|
|
{
|
|
return $query->whereNotNull('rolechangedate')
|
|
->whereBetween('rolechangedate', [now(), now()->addDays($days)]);
|
|
}
|
|
|
|
/**
|
|
* Scope to get users with expired roles.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeExpired(Builder $query): Builder
|
|
{
|
|
return $query->whereNotNull('rolechangedate')
|
|
->where('rolechangedate', '<', now());
|
|
}
|
|
|
|
/**
|
|
* Scope to exclude sharing email.
|
|
*
|
|
* @param Builder<User> $query
|
|
* @return Builder<User>
|
|
*/
|
|
public function scopeExcludeSharing(Builder $query): Builder
|
|
{
|
|
return $query->where('email', '!=', 'sharing@nZEDb.com');
|
|
}
|
|
|
|
// ===== Attribute Accessors =====
|
|
|
|
/**
|
|
* Get the user's full name.
|
|
*/
|
|
protected function fullName(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): string => trim("{$this->firstname} {$this->lastname}") ?: $this->username,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get the user's timezone or default.
|
|
*/
|
|
protected function effectiveTimezone(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): string => $this->timezone ?? 'UTC',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if user has admin privileges.
|
|
*/
|
|
protected function isAdmin(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): bool => $this->roles_id === UserRole::ADMIN->value,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if user is disabled.
|
|
*/
|
|
protected function isDisabled(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): bool => $this->roles_id === UserRole::DISABLED->value,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if role is expired.
|
|
*/
|
|
protected function isRoleExpired(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): bool => $this->rolechangedate?->isPast() ?? false,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get days until role expires.
|
|
*/
|
|
protected function daysUntilExpiry(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): ?int => $this->rolechangedate
|
|
? (int) now()->diffInDays($this->rolechangedate, false)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get country code from IP address lookup.
|
|
* Uses ip-api.com with caching to minimize API calls.
|
|
*/
|
|
protected function countryCode(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): ?string => $this->getCountryFromIp()['countryCode'] ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get country name from IP address lookup.
|
|
* Uses ip-api.com with caching to minimize API calls.
|
|
*/
|
|
protected function countryName(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: fn (): ?string => $this->getCountryFromIp()['country'] ?? null,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get country flag emoji from country code.
|
|
* Converts 2-letter country code to Unicode regional indicator symbols.
|
|
*/
|
|
protected function countryFlag(): Attribute // @phpstan-ignore missingType.generics
|
|
{
|
|
return Attribute::make(
|
|
get: function (): ?string {
|
|
$countryCode = $this->getCountryFromIp()['countryCode'] ?? null;
|
|
|
|
if (empty($countryCode) || strlen($countryCode) !== 2) {
|
|
return null;
|
|
}
|
|
|
|
// Convert country code to regional indicator symbols (flag emoji)
|
|
// Each letter is converted to its regional indicator equivalent
|
|
// A = 🇦 (U+1F1E6), B = 🇧 (U+1F1E7), etc.
|
|
$countryCode = strtoupper($countryCode);
|
|
$flag = '';
|
|
|
|
for ($i = 0; $i < 2; $i++) {
|
|
$char = ord($countryCode[$i]) - ord('A') + 0x1F1E6;
|
|
$flag .= mb_chr($char, 'UTF-8');
|
|
}
|
|
|
|
return $flag;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Lookup country information from IP address using ip-api.com.
|
|
*
|
|
* @return array{country: string|null, countryCode: string|null}
|
|
*/
|
|
protected function getCountryFromIp(): array
|
|
{
|
|
$ip = $this->host;
|
|
|
|
if (empty($ip)) {
|
|
return ['country' => null, 'countryCode' => null];
|
|
}
|
|
|
|
// Skip private/local IPs
|
|
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
|
return ['country' => null, 'countryCode' => null];
|
|
}
|
|
|
|
$cacheKey = 'ip_country_lookup_'.md5((string) $ip);
|
|
|
|
return Cache::remember($cacheKey, 86400, function () use ($ip) {
|
|
return $this->fetchCountryFromApi($ip);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fetch country info from ip-api.com.
|
|
*
|
|
* @return array{country: string|null, countryCode: string|null}
|
|
*/
|
|
protected function fetchCountryFromApi(string $ip): array
|
|
{
|
|
try {
|
|
$response = Http::timeout(3)
|
|
->retry(2, 100)
|
|
->get("http://ip-api.com/json/{$ip}", [
|
|
'fields' => 'status,country,countryCode',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
Log::debug('Country lookup failed', ['ip' => $ip, 'status' => $response->status()]);
|
|
|
|
return ['country' => null, 'countryCode' => null];
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
if (($data['status'] ?? '') !== 'success') {
|
|
return ['country' => null, 'countryCode' => null];
|
|
}
|
|
|
|
return [
|
|
'country' => $data['country'] ?? null,
|
|
'countryCode' => $data['countryCode'] ?? null,
|
|
];
|
|
} catch (\Exception $e) {
|
|
Log::debug('Country lookup exception', ['ip' => $ip, 'error' => $e->getMessage()]);
|
|
|
|
return ['country' => null, 'countryCode' => null];
|
|
}
|
|
}
|
|
|
|
// ===== Pending Role Methods =====
|
|
|
|
/**
|
|
* Check if user has a pending stacked role.
|
|
*/
|
|
public function hasPendingRole(): bool
|
|
{
|
|
return $this->pending_roles_id !== null && $this->pending_role_start_date !== null;
|
|
}
|
|
|
|
/**
|
|
* Get the pending role.
|
|
*/
|
|
public function getPendingRole(): ?Role
|
|
{
|
|
if (! $this->hasPendingRole()) {
|
|
return null;
|
|
}
|
|
|
|
return $this->pendingRole;
|
|
}
|
|
|
|
/**
|
|
* Cancel a pending stacked role.
|
|
*/
|
|
public function cancelPendingRole(): bool
|
|
{
|
|
if (! $this->hasPendingRole()) {
|
|
return false;
|
|
}
|
|
|
|
return $this->update([
|
|
'pending_roles_id' => null,
|
|
'pending_role_start_date' => null,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Get all pending stacked role changes for this user.
|
|
* Returns an array of stacked role changes that haven't been activated yet (effective_date is in the future).
|
|
*
|
|
* @return \Illuminate\Support\Collection<int, array{
|
|
* role: Role|null,
|
|
* role_name: string,
|
|
* start_date: string,
|
|
* end_date: string|null,
|
|
* is_current_pending: bool
|
|
* }>
|
|
*/
|
|
public function getAllPendingStackedRoles(): \Illuminate\Support\Collection
|
|
{
|
|
// Get all stacked role changes from history where effective_date is in the future
|
|
$stackedHistory = UserRoleHistory::where('user_id', $this->id)
|
|
->where('is_stacked', true)
|
|
->where('effective_date', '>', now())
|
|
->orderBy('effective_date', 'asc')
|
|
->get();
|
|
|
|
return $stackedHistory->map(function ($history) { // @phpstan-ignore return.type
|
|
$role = Role::find($history->new_role_id);
|
|
|
|
return [
|
|
'role' => $role,
|
|
'role_name' => $role?->name ?? 'Unknown Role', // @phpstan-ignore nullsafe.neverNull
|
|
'start_date' => $history->effective_date,
|
|
'end_date' => $history->new_expiry_date,
|
|
'is_current_pending' => $this->pending_roles_id === $history->new_role_id
|
|
&& $this->pending_role_start_date?->equalTo($history->effective_date),
|
|
];
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get comprehensive role expiry information.
|
|
*
|
|
* @return array{
|
|
* current_role: Role|null,
|
|
* current_expiry: Carbon|null,
|
|
* has_expiry: bool,
|
|
* is_expired: bool,
|
|
* days_until_expiry: int|null,
|
|
* pending_role: Role|null,
|
|
* pending_start: Carbon|null,
|
|
* has_pending_role: bool
|
|
* }
|
|
*/
|
|
public function getRoleExpiryInfo(): array
|
|
{
|
|
return [
|
|
'current_role' => $this->role,
|
|
'current_expiry' => $this->rolechangedate,
|
|
'has_expiry' => $this->rolechangedate !== null,
|
|
'is_expired' => $this->is_role_expired,
|
|
'days_until_expiry' => $this->days_until_expiry,
|
|
'pending_role' => $this->getPendingRole(),
|
|
'pending_start' => $this->pending_role_start_date,
|
|
'has_pending_role' => $this->hasPendingRole(),
|
|
];
|
|
}
|
|
|
|
// ===== Static Query Methods =====
|
|
|
|
/**
|
|
* Get count of users matching filters.
|
|
*/
|
|
public static function getCount(
|
|
?string $role = null,
|
|
?string $username = '',
|
|
?string $host = '',
|
|
?string $email = '',
|
|
?string $createdFrom = '',
|
|
?string $createdTo = '',
|
|
?string $verified = '',
|
|
): int {
|
|
return self::adminListBaseQuery([
|
|
'role' => (string) $role,
|
|
'username' => (string) $username,
|
|
'host' => (string) $host,
|
|
'email' => (string) $email,
|
|
'created_from' => (string) $createdFrom,
|
|
'created_to' => (string) $createdTo,
|
|
'verified' => (string) $verified,
|
|
])
|
|
->count();
|
|
}
|
|
|
|
/**
|
|
* Find user by username.
|
|
*/
|
|
public static function findByUsername(string $username): ?static
|
|
{
|
|
return self::whereUsername($username)->first();
|
|
}
|
|
|
|
/**
|
|
* Find user by email.
|
|
*/
|
|
public static function findByEmail(string $email): ?static
|
|
{
|
|
return static::whereEmail($email)->first();
|
|
}
|
|
|
|
/**
|
|
* Find user by RSS token.
|
|
*/
|
|
public static function findByRssToken(string $token): ?static
|
|
{
|
|
return static::whereApiToken($token)->first();
|
|
}
|
|
|
|
/**
|
|
* Build a query for a verified user resolved by API/RSS token.
|
|
*/
|
|
public static function verifiedApiTokenQuery(string $token): Builder // @phpstan-ignore missingType.generics
|
|
{
|
|
return static::query()
|
|
->verified()
|
|
->whereApiToken($token);
|
|
}
|
|
|
|
/**
|
|
* Find a verified user by API/RSS token.
|
|
*/
|
|
public static function findVerifiedByApiToken(string $token): ?static
|
|
{
|
|
return static::verifiedApiTokenQuery($token)->first();
|
|
}
|
|
|
|
/**
|
|
* Find user by password reset GUID.
|
|
*/
|
|
public static function findByResetGuid(string $guid): ?static
|
|
{
|
|
if (static::whereApiToken($guid)->exists()) {
|
|
return null;
|
|
}
|
|
|
|
return static::whereResetguid($guid)
|
|
->first();
|
|
}
|
|
|
|
// ===== User Management Methods =====
|
|
|
|
/**
|
|
* Delete user by ID.
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function deleteUser(int $id): void
|
|
{
|
|
static::findOrFail($id)->delete();
|
|
}
|
|
|
|
/**
|
|
* Update user details.
|
|
*/
|
|
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 {
|
|
$user = static::findOrFail($id);
|
|
$roleModel = Role::find($role);
|
|
|
|
$user->update([
|
|
'username' => trim($userName),
|
|
'grabs' => $grabs,
|
|
'roles_id' => $role,
|
|
'notes' => substr($notes ?? '', 0, 255),
|
|
'invites' => $invites,
|
|
'movieview' => $movieview,
|
|
'musicview' => $musicview,
|
|
'gameview' => $gameview,
|
|
'xxxview' => $xxxview,
|
|
'consoleview' => $consoleview,
|
|
'bookview' => $bookview,
|
|
'style' => $style,
|
|
'rate_limit' => $roleModel?->rate_limit ?? 60, // @phpstan-ignore nullsafe.neverNull
|
|
...($email ? ['email' => trim($email)] : []),
|
|
]);
|
|
|
|
if ($roleModel) {
|
|
$user->syncRoles([$roleModel->name]);
|
|
}
|
|
|
|
return SignupError::SUCCESS->value;
|
|
}
|
|
|
|
/**
|
|
* Update user role with optional stacking and promotions.
|
|
*/
|
|
public static function updateUserRole(
|
|
int $uid,
|
|
int|string $role,
|
|
bool $applyPromotions = true,
|
|
bool $stackRole = true,
|
|
?int $changedBy = null,
|
|
?string $originalExpiryBeforeEdits = null,
|
|
bool $preserveCurrentExpiry = false,
|
|
?int $addYears = null,
|
|
): bool {
|
|
Log::info('updateUserRole called', [
|
|
'uid' => $uid,
|
|
'role' => $role,
|
|
'applyPromotions' => $applyPromotions,
|
|
'stackRole' => $stackRole,
|
|
'changedBy' => $changedBy,
|
|
'originalExpiryBeforeEdits' => $originalExpiryBeforeEdits,
|
|
'preserveCurrentExpiry' => $preserveCurrentExpiry,
|
|
'addYears' => $addYears,
|
|
]);
|
|
|
|
$roleModel = is_numeric($role)
|
|
? Role::find((int) $role)
|
|
: Role::where('name', $role)->first();
|
|
|
|
if (! $roleModel) {
|
|
Log::error('Role not found', ['role' => $role]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$user = static::find($uid);
|
|
if (! $user) {
|
|
Log::error('User not found', ['uid' => $uid]);
|
|
|
|
return false;
|
|
}
|
|
|
|
$currentRoleId = $user->roles_id;
|
|
$oldExpiryDate = $originalExpiryBeforeEdits
|
|
? Carbon::parse($originalExpiryBeforeEdits)
|
|
: $user->rolechangedate;
|
|
$currentExpiryDate = $user->rolechangedate;
|
|
|
|
Log::info('updateUserRole - expiry date values', [
|
|
'originalExpiryBeforeEdits_raw' => $originalExpiryBeforeEdits,
|
|
'user_rolechangedate_raw' => $user->rolechangedate,
|
|
'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
|
|
'currentExpiryDate' => $currentExpiryDate?->toDateTimeString(),
|
|
'currentRoleId' => $currentRoleId,
|
|
'newRoleId' => $roleModel->id,
|
|
]);
|
|
|
|
// No role change needed
|
|
if ($currentRoleId === $roleModel->id) {
|
|
return self::handleSameRoleUpdate(
|
|
$user,
|
|
$roleModel,
|
|
$applyPromotions,
|
|
$addYears,
|
|
$currentExpiryDate
|
|
);
|
|
}
|
|
|
|
// Determine if we should stack
|
|
$shouldStack = $stackRole && $currentExpiryDate?->isFuture();
|
|
|
|
if ($shouldStack) {
|
|
return self::handleStackedRoleChange(
|
|
$user,
|
|
$roleModel,
|
|
$currentRoleId,
|
|
$oldExpiryDate,
|
|
$currentExpiryDate,
|
|
$applyPromotions,
|
|
$addYears,
|
|
$changedBy
|
|
);
|
|
}
|
|
|
|
return self::handleImmediateRoleChange(
|
|
$user,
|
|
$roleModel,
|
|
$currentRoleId,
|
|
$oldExpiryDate,
|
|
$currentExpiryDate,
|
|
$applyPromotions,
|
|
$addYears,
|
|
$changedBy,
|
|
$preserveCurrentExpiry
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle role update when role is not changing.
|
|
*/
|
|
private static function handleSameRoleUpdate(
|
|
self $user,
|
|
Role $roleModel,
|
|
bool $applyPromotions,
|
|
?int $addYears,
|
|
?Carbon $currentExpiryDate,
|
|
): bool {
|
|
if (in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true)) {
|
|
return true;
|
|
}
|
|
|
|
$additionalDays = self::resolveRoleDurationDays($roleModel, $addYears);
|
|
$promotionDays = $applyPromotions
|
|
? RolePromotion::calculateAdditionalDays($roleModel->id)
|
|
: 0;
|
|
$totalDays = $additionalDays + $promotionDays;
|
|
|
|
if ($totalDays <= 0) {
|
|
return true;
|
|
}
|
|
|
|
$newExpiryDate = $currentExpiryDate?->isFuture()
|
|
? $currentExpiryDate->copy()->addDays($totalDays)
|
|
: now()->addDays($totalDays);
|
|
|
|
$updated = $user->update(['rolechangedate' => $newExpiryDate]);
|
|
|
|
if ($updated && $promotionDays > 0) {
|
|
self::trackPromotionApplication($user, $roleModel->id, $currentExpiryDate, $newExpiryDate);
|
|
}
|
|
|
|
return $updated;
|
|
}
|
|
|
|
/**
|
|
* Handle stacked role change.
|
|
*/
|
|
private static function handleStackedRoleChange(
|
|
self $user,
|
|
Role $roleModel,
|
|
int $currentRoleId,
|
|
?Carbon $oldExpiryDate,
|
|
Carbon $currentExpiryDate,
|
|
bool $applyPromotions,
|
|
?int $addYears,
|
|
?int $changedBy,
|
|
): bool {
|
|
// Determine the stacking start date
|
|
// If user already has a pending stacked role, we need to calculate when THAT role would expire
|
|
// and use that as the starting point for this new stacked role
|
|
|
|
$stackingStartDate = $currentExpiryDate;
|
|
|
|
// Check if there's already a pending role - if so, calculate when it would expire
|
|
if ($user->hasPendingRole()) {
|
|
$pendingRole = $user->getPendingRole();
|
|
$pendingStartDate = $user->pending_role_start_date;
|
|
|
|
if ($pendingRole && $pendingStartDate) {
|
|
// Calculate when the pending role would expire
|
|
$pendingRoleBaseDays = self::resolveRoleDurationDays($pendingRole, null);
|
|
$pendingRolePromotionDays = ! in_array($pendingRole->name, self::PROMOTION_EXCLUDED_ROLES, true)
|
|
? RolePromotion::calculateAdditionalDays($pendingRole->id)
|
|
: 0;
|
|
$pendingRoleTotalDays = $pendingRoleBaseDays + $pendingRolePromotionDays;
|
|
$pendingRoleExpiryDate = Carbon::parse($pendingStartDate)->addDays($pendingRoleTotalDays);
|
|
|
|
Log::info('Existing pending role detected - calculating new stacking start date', [
|
|
'pendingRoleId' => $pendingRole->id,
|
|
'pendingRoleName' => $pendingRole->name,
|
|
'pendingStartDate' => $pendingStartDate->toDateTimeString(),
|
|
'pendingRoleBaseDays' => $pendingRoleBaseDays,
|
|
'pendingRolePromotionDays' => $pendingRolePromotionDays,
|
|
'pendingRoleExpiryDate' => $pendingRoleExpiryDate->toDateTimeString(),
|
|
]);
|
|
|
|
// Use the pending role's expiry date as the new stacking start date
|
|
if ($pendingRoleExpiryDate->isFuture()) {
|
|
$stackingStartDate = $pendingRoleExpiryDate;
|
|
}
|
|
}
|
|
} else {
|
|
// No pending role - use the latest of oldExpiryDate and currentExpiryDate
|
|
Log::info('No pending role - comparing old and current expiry dates', [
|
|
'oldExpiryDate' => $oldExpiryDate?->toDateTimeString(),
|
|
'currentExpiryDate' => $currentExpiryDate->toDateTimeString(),
|
|
]);
|
|
|
|
// Use the greater of the two dates if old expiry exists and is in the future
|
|
if ($oldExpiryDate !== null && $oldExpiryDate->isFuture() && $oldExpiryDate->gt($currentExpiryDate)) {
|
|
$stackingStartDate = $oldExpiryDate;
|
|
}
|
|
}
|
|
|
|
Log::info('Final stacking start date selected', [
|
|
'stackingStartDate' => $stackingStartDate->toDateTimeString(),
|
|
'hadPendingRole' => $user->hasPendingRole(),
|
|
]);
|
|
|
|
$baseDays = self::resolveRoleDurationDays($roleModel, $addYears);
|
|
$promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions
|
|
? RolePromotion::calculateAdditionalDays($roleModel->id)
|
|
: 0;
|
|
$totalDays = $baseDays + $promotionDays;
|
|
$newExpiryDate = $stackingStartDate->copy()->addDays($totalDays);
|
|
|
|
$user->update([
|
|
'pending_roles_id' => $roleModel->id,
|
|
'pending_role_start_date' => $stackingStartDate,
|
|
]);
|
|
|
|
try {
|
|
UserRoleHistory::recordRoleChange(
|
|
userId: $user->id,
|
|
oldRoleId: $currentRoleId,
|
|
newRoleId: $roleModel->id,
|
|
oldExpiryDate: $stackingStartDate,
|
|
newExpiryDate: $newExpiryDate,
|
|
effectiveDate: $stackingStartDate,
|
|
isStacked: true,
|
|
changeReason: 'stacked_role_change',
|
|
changedBy: $changedBy
|
|
);
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to record role history', ['error' => $e->getMessage()]);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle immediate role change.
|
|
*/
|
|
private static function handleImmediateRoleChange(
|
|
self $user,
|
|
Role $roleModel,
|
|
int $currentRoleId,
|
|
?Carbon $oldExpiryDate,
|
|
?Carbon $currentExpiryDate,
|
|
bool $applyPromotions,
|
|
?int $addYears,
|
|
?int $changedBy,
|
|
bool $preserveCurrentExpiry,
|
|
): bool {
|
|
$baseDays = self::resolveRoleDurationDays($roleModel, $addYears);
|
|
$promotionDays = ! in_array($roleModel->name, self::PROMOTION_EXCLUDED_ROLES, true) && $applyPromotions
|
|
? RolePromotion::calculateAdditionalDays($roleModel->id)
|
|
: 0;
|
|
$totalDays = $baseDays + $promotionDays;
|
|
|
|
$newExpiryDate = match (true) {
|
|
$preserveCurrentExpiry && $currentExpiryDate !== null => $currentExpiryDate,
|
|
$totalDays > 0 => now()->addDays($totalDays),
|
|
default => null,
|
|
};
|
|
|
|
$updated = $user->update([
|
|
'roles_id' => $roleModel->id,
|
|
'rolechangedate' => $newExpiryDate,
|
|
]);
|
|
|
|
$user->syncRoles([$roleModel->name]);
|
|
|
|
if ($updated) {
|
|
try {
|
|
UserRoleHistory::recordRoleChange(
|
|
userId: $user->id,
|
|
oldRoleId: $currentRoleId,
|
|
newRoleId: $roleModel->id,
|
|
oldExpiryDate: $oldExpiryDate,
|
|
newExpiryDate: $newExpiryDate,
|
|
effectiveDate: now(),
|
|
isStacked: false,
|
|
changeReason: 'immediate_role_change',
|
|
changedBy: $changedBy
|
|
);
|
|
} catch (\Exception $e) {
|
|
Log::error('Failed to record role history', ['error' => $e->getMessage()]);
|
|
}
|
|
|
|
if ($applyPromotions && $promotionDays > 0) {
|
|
self::trackPromotionApplication($user, $roleModel->id, $oldExpiryDate, $newExpiryDate);
|
|
}
|
|
}
|
|
|
|
return $updated;
|
|
}
|
|
|
|
/**
|
|
* Resolve the number of role-duration days to apply.
|
|
*/
|
|
private static function resolveRoleDurationDays(Role $roleModel, ?int $addYears, bool $useRoleDefaultWhenMissing = true): int
|
|
{
|
|
if ($addYears !== null) {
|
|
return max(0, $addYears) * self::DAYS_PER_YEAR;
|
|
}
|
|
|
|
if (! $useRoleDefaultWhenMissing) {
|
|
return 0;
|
|
}
|
|
|
|
$rawAddYears = Role::query()->whereKey($roleModel->id)->value('addyears');
|
|
$roleAddYears = is_numeric($rawAddYears)
|
|
? (int) $rawAddYears
|
|
: (is_numeric($roleModel->addyears ?? null) ? (int) $roleModel->addyears : 0);
|
|
|
|
return max(0, $roleAddYears) * self::DAYS_PER_YEAR;
|
|
}
|
|
|
|
/**
|
|
* Track promotion application statistics.
|
|
*/
|
|
private static function trackPromotionApplication(
|
|
self $user,
|
|
int $roleId,
|
|
?Carbon $oldExpiryDate,
|
|
?Carbon $newExpiryDate,
|
|
): void {
|
|
$promotions = RolePromotion::getActivePromotions($roleId);
|
|
foreach ($promotions as $promotion) {
|
|
$promotion->trackApplication($user->id, $roleId, $oldExpiryDate, $newExpiryDate);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process expired roles and send notifications.
|
|
*/
|
|
public static function updateExpiredRoles(): void
|
|
{
|
|
$now = CarbonImmutable::now();
|
|
|
|
// Send expiration warnings
|
|
self::sendExpirationWarnings($now);
|
|
|
|
// Process expired roles
|
|
self::processExpiredRoles($now);
|
|
}
|
|
|
|
/**
|
|
* Send expiration warning emails.
|
|
*/
|
|
private static function sendExpirationWarnings(CarbonImmutable $now): void
|
|
{
|
|
$periods = [
|
|
'day' => $now->addDay(),
|
|
'week' => $now->addWeek(),
|
|
'month' => $now->addMonth(),
|
|
];
|
|
|
|
foreach ($periods as $period) {
|
|
$users = static::whereDate('rolechangedate', '=', $period)->get();
|
|
$days = $now->diffInDays($period, true);
|
|
|
|
foreach ($users as $user) {
|
|
SendAccountWillExpireEmail::dispatch($user, $days)->onQueue('emails'); // @phpstan-ignore argument.type
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process users with expired roles.
|
|
*/
|
|
private static function processExpiredRoles(CarbonImmutable $now): void
|
|
{
|
|
static::expired()->each(function (self $user) use ($now) {
|
|
$oldRoleId = $user->roles_id;
|
|
$oldExpiryDate = $user->rolechangedate;
|
|
|
|
// Check for pending stacked role
|
|
if ($user->hasPendingRole() && $user->pending_role_start_date?->lte($now)) {
|
|
self::activatePendingRole($user, $oldRoleId, $oldExpiryDate, $now);
|
|
|
|
return;
|
|
}
|
|
|
|
// Downgrade to default user role
|
|
self::downgradeToDefaultRole($user, $oldRoleId, $oldExpiryDate, $now);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Activate a pending stacked role.
|
|
*/
|
|
private static function activatePendingRole(
|
|
self $user,
|
|
int $oldRoleId,
|
|
?Carbon $oldExpiryDate,
|
|
CarbonImmutable $now,
|
|
): void {
|
|
$roleModel = Role::find($user->pending_roles_id);
|
|
if (! $roleModel) {
|
|
return;
|
|
}
|
|
|
|
$baseDays = $roleModel->addyears * self::DAYS_PER_YEAR;
|
|
$promotionDays = RolePromotion::calculateAdditionalDays($roleModel->id);
|
|
$totalDays = $baseDays + $promotionDays;
|
|
$newExpiryDate = $totalDays > 0 ? $now->addDays($totalDays) : null;
|
|
|
|
$user->update([
|
|
'roles_id' => $roleModel->id,
|
|
'rolechangedate' => $newExpiryDate,
|
|
'pending_roles_id' => null,
|
|
'pending_role_start_date' => null,
|
|
]);
|
|
$user->syncRoles([$roleModel->name]);
|
|
|
|
UserRoleHistory::recordRoleChange(
|
|
userId: $user->id,
|
|
oldRoleId: $oldRoleId,
|
|
newRoleId: $roleModel->id,
|
|
oldExpiryDate: $oldExpiryDate,
|
|
newExpiryDate: $newExpiryDate,
|
|
effectiveDate: Carbon::instance($now),
|
|
isStacked: true,
|
|
changeReason: 'stacked_role_activated',
|
|
changedBy: null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Downgrade user to default role.
|
|
*/
|
|
private static function downgradeToDefaultRole(
|
|
self $user,
|
|
int $oldRoleId,
|
|
?Carbon $oldExpiryDate,
|
|
CarbonImmutable $now,
|
|
): void {
|
|
$user->update([
|
|
'roles_id' => UserRole::USER->value,
|
|
'rolechangedate' => null,
|
|
'pending_roles_id' => null,
|
|
'pending_role_start_date' => null,
|
|
]);
|
|
$user->syncRoles(['User']);
|
|
|
|
UserRoleHistory::recordRoleChange(
|
|
userId: $user->id,
|
|
oldRoleId: $oldRoleId,
|
|
newRoleId: UserRole::USER->value,
|
|
oldExpiryDate: $oldExpiryDate,
|
|
newExpiryDate: null,
|
|
effectiveDate: Carbon::instance($now),
|
|
isStacked: false,
|
|
changeReason: 'role_expired',
|
|
changedBy: null
|
|
);
|
|
|
|
SendAccountExpiredEmail::dispatch($user)->onQueue('emails');
|
|
}
|
|
|
|
/**
|
|
* Get paginated user list with filters.
|
|
*
|
|
* @return Collection<int, static>
|
|
*/
|
|
public static function getRange(
|
|
int|false $start,
|
|
int $offset,
|
|
string $orderBy,
|
|
?string $userName = '',
|
|
?string $email = '',
|
|
?string $host = '',
|
|
?string $role = '',
|
|
?string $createdFrom = '',
|
|
?string $createdTo = '',
|
|
?string $verified = '',
|
|
): Collection {
|
|
$order = self::getBrowseOrder($orderBy);
|
|
|
|
return self::adminListBaseQuery([
|
|
'username' => (string) $userName,
|
|
'email' => (string) $email,
|
|
'host' => (string) $host,
|
|
'role' => (string) $role,
|
|
'created_from' => (string) $createdFrom,
|
|
'created_to' => (string) $createdTo,
|
|
'verified' => (string) $verified,
|
|
])
|
|
->withCount([
|
|
'requests as daily_api_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
|
|
'downloads as daily_download_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
|
|
])
|
|
->with('roles:id,name')
|
|
->orderBy($order[0], $order[1])
|
|
->when($start !== false, fn (Builder $q) => $q->offset($start)->limit($offset))
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @param array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string} $filters
|
|
* @return LengthAwarePaginator<int, User>
|
|
*/
|
|
public static function adminListPaginator(array $filters, string $orderBy, int $perPage): LengthAwarePaginator
|
|
{
|
|
$order = self::getBrowseOrder($orderBy);
|
|
|
|
return self::adminListBaseQuery($filters)
|
|
->withCount([
|
|
'requests as daily_api_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
|
|
'downloads as daily_download_count' => fn (Builder $q) => $q->where('timestamp', '>', now()->subDay()),
|
|
])
|
|
->with('roles:id,name')
|
|
->orderBy($order[0], $order[1])
|
|
->paginate($perPage);
|
|
}
|
|
|
|
/**
|
|
* @param array{username?: string, email?: string, host?: string, role?: string, verified?: string, created_from?: string, created_to?: string} $filters
|
|
* @return Builder<User>
|
|
*/
|
|
private static function adminListBaseQuery(array $filters): Builder
|
|
{
|
|
$username = $filters['username'] ?? '';
|
|
$email = $filters['email'] ?? '';
|
|
$host = $filters['host'] ?? '';
|
|
$role = $filters['role'] ?? '';
|
|
$verified = $filters['verified'] ?? '';
|
|
$createdFrom = $filters['created_from'] ?? '';
|
|
$createdTo = $filters['created_to'] ?? '';
|
|
|
|
return self::query()
|
|
->withTrashed()
|
|
->select('users.*')
|
|
->selectSub(
|
|
'SELECT name FROM roles WHERE roles.id = users.roles_id',
|
|
'rolename'
|
|
)
|
|
->excludeSharing()
|
|
->when($username, fn (Builder $q) => $q->where('username', 'like', "%{$username}%"))
|
|
->when($email, fn (Builder $q) => $q->where('email', 'like', "%{$email}%"))
|
|
->when($host, fn (Builder $q) => $q->where('host', 'like', "%{$host}%"))
|
|
->when($role, fn (Builder $q) => $q->where('roles_id', $role))
|
|
->when($createdFrom, fn (Builder $q) => $q->where('created_at', '>=', "{$createdFrom} 00:00:00"))
|
|
->when($createdTo, fn (Builder $q) => $q->where('created_at', '<=', "{$createdTo} 23:59:59"))
|
|
->when($verified === '1', fn (Builder $q) => $q->where(function (Builder $verifiedQuery): void {
|
|
$verifiedQuery->where('verified', true)
|
|
->orWhereNotNull('email_verified_at');
|
|
}))
|
|
->when($verified === '0', fn (Builder $q) => $q->where('verified', false)->whereNull('email_verified_at'));
|
|
}
|
|
|
|
/**
|
|
* Get sort configuration for user browsing.
|
|
*
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
public static function getBrowseOrder(string $orderBy = ''): array
|
|
{
|
|
$order = $orderBy ?: 'username_desc';
|
|
$parts = explode('_', $order);
|
|
|
|
$field = match ($parts[0]) {
|
|
'email' => 'email',
|
|
'host' => 'host',
|
|
'createdat' => 'created_at',
|
|
'lastlogin' => 'lastlogin',
|
|
'apiaccess' => 'apiaccess',
|
|
'apirequests' => 'daily_api_count',
|
|
'grabs' => 'grabs',
|
|
'role' => 'rolename',
|
|
'rolechangedate' => 'rolechangedate',
|
|
'verification' => 'verified',
|
|
default => 'username',
|
|
};
|
|
|
|
$direction = isset($parts[1]) && preg_match('/^asc|desc$/i', $parts[1])
|
|
? $parts[1]
|
|
: 'desc';
|
|
|
|
return [$field, $direction];
|
|
}
|
|
|
|
// ===== Password Methods =====
|
|
|
|
/**
|
|
* Verify password and rehash if needed.
|
|
*/
|
|
public static function checkPassword(string $password, string $hash, int $userId = -1): bool
|
|
{
|
|
if (! Hash::check($password, $hash)) {
|
|
return false;
|
|
}
|
|
|
|
if ($userId > 0 && Hash::needsRehash($hash)) {
|
|
static::find($userId)?->update(['password' => Hash::make($password)]);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Hash a password.
|
|
*/
|
|
public static function hashPassword(string $password): string
|
|
{
|
|
return Hash::make($password);
|
|
}
|
|
|
|
/**
|
|
* Generate a secure random password.
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function generatePassword(int $length = 15): string
|
|
{
|
|
return Str::password($length);
|
|
}
|
|
|
|
// ===== Update Methods =====
|
|
|
|
/**
|
|
* Regenerate RSS key.
|
|
*/
|
|
public static function updateRssKey(int $uid): int
|
|
{
|
|
static::find($uid)?->update([
|
|
'api_token' => md5(Str::random(60)),
|
|
]);
|
|
|
|
return SignupError::SUCCESS->value;
|
|
}
|
|
|
|
/**
|
|
* Update password reset GUID.
|
|
*/
|
|
public static function updatePassResetGuid(int $id, ?string $guid): int
|
|
{
|
|
static::find($id)?->update(['resetguid' => $guid]);
|
|
|
|
return SignupError::SUCCESS->value;
|
|
}
|
|
|
|
/**
|
|
* Update user password.
|
|
*/
|
|
public static function updatePassword(int $id, string $password): int
|
|
{
|
|
static::find($id)?->update(['password' => Hash::make($password)]);
|
|
|
|
return SignupError::SUCCESS->value;
|
|
}
|
|
|
|
/**
|
|
* Update user role change date.
|
|
*/
|
|
public static function updateUserRoleChangeDate(int $id, string $roleChangeDate): int
|
|
{
|
|
static::find($id)?->update(['rolechangedate' => $roleChangeDate]);
|
|
|
|
return SignupError::SUCCESS->value;
|
|
}
|
|
|
|
/**
|
|
* Increment user's grab count.
|
|
*/
|
|
public static function incrementGrabs(int $id, int $num = 1): void
|
|
{
|
|
static::query()
|
|
->whereKey($id)
|
|
->increment('grabs', $num, ['lastdownload' => now()]);
|
|
}
|
|
|
|
// ===== Validation Methods =====
|
|
|
|
/**
|
|
* Validate a URL format.
|
|
*/
|
|
public static function isValidUrl(string $url): bool
|
|
{
|
|
return (bool) preg_match(
|
|
'/^(https?|ftp):\/\/([A-Z0-9][A-Z0-9_-]*(?:\.[A-Z0-9][A-Z0-9_-]*)+):?(\d+)?\/?/i',
|
|
$url
|
|
);
|
|
}
|
|
|
|
// ===== Registration Methods =====
|
|
|
|
/**
|
|
* Register a new user.
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function signUp(
|
|
string $userName,
|
|
string $password,
|
|
string $email,
|
|
string $host,
|
|
?string $notes,
|
|
int $invites = Invitation::DEFAULT_INVITES,
|
|
string $inviteCode = '',
|
|
bool $forceInviteMode = false,
|
|
int $role = UserRole::USER->value,
|
|
bool $validate = true,
|
|
): int|string {
|
|
$userData = [
|
|
'username' => trim($userName),
|
|
'password' => trim($password),
|
|
'email' => trim($email),
|
|
];
|
|
|
|
if ($validate) {
|
|
$validator = Validator::make($userData, [
|
|
'username' => ['required', 'string', 'min:5', 'max:255', 'unique:users'],
|
|
'email' => ['required', 'string', 'email', 'max:255', 'unique:users', new ValidEmailDomain],
|
|
'password' => [
|
|
'required',
|
|
'string',
|
|
'min:8',
|
|
'confirmed',
|
|
'regex:/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/',
|
|
],
|
|
]);
|
|
|
|
if ($validator->fails()) {
|
|
return implode('', Arr::collapse($validator->errors()->toArray()));
|
|
}
|
|
}
|
|
|
|
$invitedBy = 0;
|
|
if (! $forceInviteMode && (int) Settings::settingValue('registerstatus') === Settings::REGISTER_STATUS_INVITE) {
|
|
if ($inviteCode === '') {
|
|
return SignupError::BAD_INVITE_CODE->value;
|
|
}
|
|
|
|
$invitedBy = self::checkAndUseInvite($inviteCode);
|
|
if ($invitedBy < 0) {
|
|
return SignupError::BAD_INVITE_CODE->value;
|
|
}
|
|
}
|
|
|
|
return self::add(
|
|
$userData['username'],
|
|
$userData['password'],
|
|
$userData['email'],
|
|
$role,
|
|
$notes,
|
|
$host,
|
|
$invites,
|
|
$invitedBy
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Validate and consume an invite code.
|
|
*/
|
|
public static function checkAndUseInvite(string $inviteCode): int
|
|
{
|
|
$invite = Invitation::findValidByToken($inviteCode);
|
|
if (! $invite) {
|
|
return -1;
|
|
}
|
|
|
|
static::where('id', $invite->invited_by)->decrement('invites');
|
|
$invite->markAsUsed(0);
|
|
|
|
return $invite->invited_by;
|
|
}
|
|
|
|
/**
|
|
* Create a new user.
|
|
*/
|
|
public static function add(
|
|
string $userName,
|
|
string $password,
|
|
string $email,
|
|
int $role,
|
|
?string $notes = '',
|
|
string $host = '',
|
|
int $invites = Invitation::DEFAULT_INVITES,
|
|
int $invitedBy = 0,
|
|
): int {
|
|
$hashedPassword = Hash::make($password);
|
|
|
|
$storeIps = config('nntmux:settings.store_user_ip') === true ? $host : '';
|
|
|
|
$user = static::create([
|
|
'username' => $userName,
|
|
'password' => $hashedPassword,
|
|
'email' => $email,
|
|
'host' => $storeIps,
|
|
'roles_id' => $role,
|
|
'invites' => $invites,
|
|
'invitedby' => $invitedBy === 0 ? null : $invitedBy,
|
|
'notes' => $notes,
|
|
]);
|
|
|
|
return $user->id;
|
|
}
|
|
|
|
// ===== Category Exclusion Methods =====
|
|
|
|
/**
|
|
* Get excluded category IDs for a user.
|
|
*
|
|
* This includes both permission-based exclusions (entire root categories)
|
|
* and user-selected subcategory exclusions from user_excluded_categories table.
|
|
*
|
|
* @return list<int>
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function getCachedCategoryExclusionById(int $userId): array
|
|
{
|
|
return Cache::remember(
|
|
static::categoryExclusionCacheKey($userId),
|
|
300,
|
|
static fn (): array => static::getCategoryExclusionById($userId)
|
|
);
|
|
}
|
|
|
|
public static function categoryExclusionCacheKey(int $userId): string
|
|
{
|
|
return 'user_category_exclusions_'.$userId;
|
|
}
|
|
|
|
/**
|
|
* @return list<int>
|
|
*/
|
|
public static function getCategoryExclusionById(int $userId): array
|
|
{
|
|
$user = static::findOrFail($userId);
|
|
|
|
$userAllowed = $user->getDirectPermissions()->pluck('name')->toArray();
|
|
$roleAllowed = $user->getAllPermissions()->pluck('name')->toArray();
|
|
$allowed = array_intersect($roleAllowed, $userAllowed);
|
|
|
|
$categoryPermissions = [
|
|
'view console' => 1000,
|
|
'view movies' => 2000,
|
|
'view audio' => 3000,
|
|
'view pc' => 4000,
|
|
'view tv' => 5000,
|
|
'view adult' => 6000,
|
|
'view books' => 7000,
|
|
'view other' => 1,
|
|
];
|
|
|
|
$excludedRoots = [];
|
|
foreach ($categoryPermissions as $permission => $rootId) {
|
|
if (! in_array($permission, $allowed, true)) {
|
|
$excludedRoots[] = $rootId;
|
|
}
|
|
}
|
|
|
|
// Get all subcategories that belong to excluded root categories
|
|
$permissionExclusions = Category::whereIn('root_categories_id', $excludedRoots)
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
// Get user's custom excluded subcategories from pivot table
|
|
$userExclusions = $user->excludedCategories()
|
|
->pluck('categories_id')
|
|
->toArray();
|
|
|
|
$filteredUserExclusions = $userExclusions === []
|
|
? []
|
|
: Category::query()
|
|
->whereIn('id', $userExclusions)
|
|
->when($excludedRoots !== [], fn (Builder $query): Builder => $query->whereNotIn('root_categories_id', $excludedRoots))
|
|
->pluck('id')
|
|
->toArray();
|
|
|
|
// Merge permission-based exclusions with user-selected subcategory exclusions
|
|
return array_values(array_unique(array_map(
|
|
static fn (mixed $categoryId): int => (int) $categoryId,
|
|
array_merge($permissionExclusions, $filteredUserExclusions)
|
|
)));
|
|
}
|
|
|
|
/**
|
|
* Sync user's excluded subcategories.
|
|
*
|
|
* @param array<int> $categoryIds Array of category IDs to exclude
|
|
*/
|
|
public function syncExcludedCategories(array $categoryIds): void
|
|
{
|
|
// Delete existing exclusions
|
|
$this->excludedCategories()->delete();
|
|
|
|
// Insert new exclusions
|
|
foreach ($categoryIds as $categoryId) {
|
|
UserExcludedCategory::create([
|
|
'users_id' => $this->id,
|
|
'categories_id' => (int) $categoryId,
|
|
]);
|
|
}
|
|
|
|
Cache::forget(static::categoryExclusionCacheKey($this->id));
|
|
}
|
|
|
|
/**
|
|
* Get excluded categories for API request.
|
|
*
|
|
* @return list<int>
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function getCategoryExclusionForApi(Request $request): array
|
|
{
|
|
$apiToken = $request->input('api_token') ?? $request->input('apikey');
|
|
$user = is_string($apiToken) && $apiToken !== ''
|
|
? static::findVerifiedByApiToken($apiToken)
|
|
: null;
|
|
|
|
return $user ? static::getCategoryExclusionById($user->id) : [];
|
|
}
|
|
|
|
// ===== Invitation Methods =====
|
|
|
|
/**
|
|
* Send an invitation email.
|
|
*
|
|
* @throws \Exception
|
|
*/
|
|
public static function sendInvite(string $serverUrl, int $uid, string $emailTo): string
|
|
{
|
|
$user = static::findOrFail($uid);
|
|
|
|
$invitation = Invitation::createInvitation($emailTo, $user->id);
|
|
$url = "{$serverUrl}/register?token={$invitation->token}";
|
|
|
|
app(InvitationService::class)->sendInvitationEmail($invitation);
|
|
|
|
return $url;
|
|
}
|
|
|
|
// ===== Cleanup Methods =====
|
|
|
|
/**
|
|
* Delete unverified User and FreeUser accounts older than 3 days.
|
|
*/
|
|
public static function deleteUnVerified(): void
|
|
{
|
|
static::query()
|
|
->whereHas('role', fn (Builder $roleQuery): Builder => $roleQuery->whereIn('name', self::UNVERIFIED_CLEANUP_ROLES))
|
|
->where('verified', false)
|
|
->whereNull('email_verified_at')
|
|
->where('created_at', '<', now()->subDays(3))
|
|
->delete();
|
|
}
|
|
|
|
/**
|
|
* Check if user can post.
|
|
*/
|
|
public static function canPost(int $userId): bool
|
|
{
|
|
return (bool) static::where('id', $userId)->value('can_post');
|
|
}
|
|
|
|
/**
|
|
* Get the user's timezone.
|
|
*/
|
|
public function getTimezone(): string
|
|
{
|
|
return $this->timezone ?? 'UTC';
|
|
}
|
|
}
|