* @license https://www.gnu.org/licenses/agpl-3.0.en.html/ GNU Affero General Public License v3.0 */ namespace App\Models; use App\Helpers\StringHelper; use App\Traits\UsersOnlineTrait; use Assada\Achievements\Achiever; use Illuminate\Auth\Notifications\ResetPassword; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOneThrough; use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; use AllowDynamicProperties; /** * App\Models\User. * * @property int $id * @property string $username * @property string $email * @property string $password * @property string|null $two_factor_secret * @property string|null $two_factor_recovery_codes * @property \Illuminate\Support\Carbon|null $two_factor_confirmed_at * @property string $passkey * @property int $group_id * @property int $uploaded * @property int $downloaded * @property string|null $image * @property string|null $title * @property string|null $about * @property string|null $signature * @property int $fl_tokens * @property string $seedbonus * @property int $invites * @property int $hitandruns * @property string $rsskey * @property int $chatroom_id * @property int $read_rules * @property bool $can_chat * @property bool $can_comment * @property bool $can_download * @property bool $can_request * @property bool $can_invite * @property bool $can_upload * @property bool $is_donor * @property bool $is_lifetime * @property string|null $remember_token * @property string|null $api_token * @property \Illuminate\Support\Carbon|null $last_login * @property \Illuminate\Support\Carbon|null $last_action * @property \Illuminate\Support\Carbon|null $disabled_at * @property int|null $deleted_by * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property int $chat_status_id * @property \Illuminate\Support\Carbon|null $deleted_at * @property int $own_flushes * @property string|null $email_verified_at */ #[AllowDynamicProperties] final class User extends Authenticatable implements MustVerifyEmail { use Achiever; /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory; use Notifiable; use SoftDeletes; use TwoFactorAuthenticatable; use UsersOnlineTrait; /** * The attributes that should be hidden for serialization. * * @var list */ protected $hidden = [ 'email', 'password', 'passkey', 'rsskey', 'remember_token', 'api_token', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at', ]; /** * The attributes that aren't mass assignable. * * @var string[] */ protected $guarded = []; /** * Get the attributes that should be cast. * * @return array{ * last_login: 'datetime', * last_action: 'datetime', * disabled_at: 'datetime', * can_comment: 'bool', * can_download: 'bool', * can_request: 'bool', * can_invite: 'bool', * can_upload: 'bool', * can_chat: 'bool', * seedbonus: 'decimal:2', * is_donor: 'bool', * is_lifetime: 'bool' * } */ protected function casts(): array { return [ 'seedbonus' => 'decimal:2', 'last_login' => 'datetime', 'last_action' => 'datetime', 'disabled_at' => 'datetime', 'two_factor_confirmed_at' => 'datetime', 'can_comment' => 'bool', 'can_download' => 'bool', 'can_request' => 'bool', 'can_invite' => 'bool', 'can_upload' => 'bool', 'can_chat' => 'bool', 'is_donor' => 'bool', 'is_lifetime' => 'bool', ]; } /** * ID of the system user. */ final public const int SYSTEM_USER_ID = 1; /** * Get the group associated with the user. * * @return BelongsTo */ public function group(): BelongsTo { return $this->belongsTo(Group::class)->withDefault([ 'color' => config('user.group.defaults.color'), 'effect' => config('user.group.defaults.effect'), 'icon' => config('user.group.defaults.icon'), 'name' => config('user.group.defaults.name'), 'slug' => config('user.group.defaults.slug'), 'position' => config('user.group.defaults.position'), 'is_admin' => config('user.group.defaults.is_admin'), 'is_freeleech' => config('user.group.defaults.is_freeleech'), 'is_immune' => config('user.group.defaults.is_immune'), 'is_incognito' => config('user.group.defaults.is_incognito'), 'is_internal' => config('user.group.defaults.is_internal'), 'is_modo' => config('user.group.defaults.is_modo'), 'is_trusted' => config('user.group.defaults.is_trusted'), 'can_upload' => config('user.group.defaults.can_upload'), 'level' => config('user.group.defaults.level'), ]); } /** * Get the internal groups that the user belongs to. * * @return BelongsToMany */ public function internals(): BelongsToMany { return $this->belongsToMany(Internal::class) ->using(InternalUser::class) ->withPivot('id', 'position', 'created_at'); } /** * Get the chatroom that contains the user. * * @return BelongsTo */ public function chatroom(): BelongsTo { return $this->belongsTo(Chatroom::class); } /** * Get the chat status associated with the user. * * @return BelongsTo */ public function chatStatus(): BelongsTo { return $this->belongsTo(ChatStatus::class, 'chat_status_id', 'id'); } /** * Get the bookmarks that belong the user. * * @return BelongsToMany */ public function bookmarks(): BelongsToMany { return $this->belongsToMany(Torrent::class, 'bookmarks', 'user_id', 'torrent_id')->withTimestamps(); } /** * Get the seeding torrents that belong to the user. * * @return BelongsToMany */ public function seedingTorrents(): BelongsToMany { return $this->belongsToMany(Torrent::class, 'history') ->wherePivot('active', '=', 1) ->wherePivot('seeder', '=', 1); } /** * Get the leeching torrents that belong to the user. * * @return BelongsToMany */ public function leechingTorrents(): BelongsToMany { return $this->belongsToMany(Torrent::class, 'history') ->wherePivot('active', '=', 1) ->wherePivot('seeder', '=', 0); } /** * Get the users that are following the user. * * @return BelongsToMany */ public function followers(): BelongsToMany { return $this->belongsToMany(User::class, 'follows', 'target_id', 'user_id') ->as('follow') ->withTimestamps(); } /** * Get the connectable seeding torrents that belong to the user. * * @return BelongsToMany */ public function connectableSeedingTorrents(): BelongsToMany { return $this->belongsToMany(Torrent::class, 'peers') ->wherePivot('seeder', '=', 1) ->wherePivot('connectable', '=', true); } /** * Get the users that the user is following. * * @return BelongsToMany */ public function following(): BelongsToMany { return $this->belongsToMany(User::class, 'follows', 'user_id', 'target_id') ->as('follow') ->withTimestamps(); } /** * Get the messages the user has sent. * * @return HasMany */ public function messages(): HasMany { return $this->hasMany(Message::class); } /** * Get the settings associated with the user. * * @return HasOne */ public function settings(): HasOne { return $this->hasOne(UserSetting::class)->withDefault([ 'censor' => false, 'news_block_visible' => true, 'news_block_position' => 0, 'chat_block_visible' => true, 'chat_block_position' => 1, 'featured_block_visible' => true, 'featured_block_position' => 2, 'random_media_block_visible' => true, 'random_media_block_position' => 3, 'poll_block_visible' => true, 'poll_block_position' => 4, 'top_torrents_block_visible' => true, 'top_torrents_block_position' => 5, 'top_users_block_visible' => true, 'top_users_block_position' => 6, 'latest_topics_block_visible' => true, 'latest_topics_block_position' => 7, 'latest_posts_block_visible' => true, 'latest_posts_block_position' => 8, 'latest_comments_block_visible' => true, 'latest_comments_block_position' => 9, 'online_block_visible' => true, 'online_block_position' => 10, 'locale' => config('app.locale'), 'style' => config('other.default_style', 0), 'torrent_layout' => 0, 'torrent_filters' => false, 'custom_css' => null, 'standalone_css' => null, 'show_poster' => false, 'unbookmark_torrents_on_completion' => false, 'torrent_sort_field' => 'bumped_at', 'torrent_search_autofocus' => false, 'show_adult_content' => true, ]); } /** * Get the user's settings object. */ public function getSettingsAttribute(): ?UserSetting { $settings = cache()->rememberForever('user-settings:by-user-id:'.$this->id, fn () => $this->getRelationValue('settings') ?? 'not found'); if ($settings === 'not found') { $settings = null; } $this->setRelation('settings', $settings); return $settings; } /** * Get the privacy settings associated with the user. * * @return HasOne */ public function privacy(): HasOne { return $this->hasOne(UserPrivacy::class); } /** * Get the user's notification object. */ public function getNotificationAttribute(): ?UserNotification { $notification = cache()->rememberForever('user-notification:by-user-id:'.$this->id, fn () => $this->getRelationValue('notification') ?? 'not found'); if ($notification === 'not found') { $notification = null; } $this->setRelation('notification', $notification); return $notification; } /** * Get the notification settings associated with the user. * * @return HasOne */ public function notification(): HasOne { return $this->hasOne(UserNotification::class); } /** * Get the user's privacy object. */ public function getPrivacyAttribute(): ?UserPrivacy { $privacy = cache()->rememberForever('user-privacy:by-user-id:'.$this->id, fn () => $this->getRelationValue('privacy') ?? 'not found'); if ($privacy === 'not found') { $privacy = null; } $this->setRelation('privacy', $privacy); return $privacy; } /** * Get the watchlist associated with the user. * * @return HasOne */ public function watchlist(): HasOne { return $this->hasOne(Watchlist::class); } /** * Get the RSS feeds the user owns. * * @return HasMany */ public function rss(): HasMany { return $this->hasMany(Rss::class); } /** * Get the echo settings for the user. * * @return HasMany */ public function echoes(): HasMany { return $this->hasMany(UserEcho::class); } /** * Get the audible settings for the user. * * @return HasMany */ public function audibles(): HasMany { return $this->hasMany(UserAudible::class); } /** * Get the thanks given to torrents by the user. * * @return HasMany */ public function thanksGiven(): HasMany { return $this->hasMany(Thank::class, 'user_id', 'id'); } /** * Get the wishes for the user. * * @return HasMany */ public function wishes(): HasMany { return $this->hasMany(Wish::class); } /** * Get the thanks received from torrents to the user. * * @return HasManyThrough */ public function thanksReceived(): HasManyThrough { return $this->hasManyThrough(Thank::class, Torrent::class); } /** * Get the polls created by the user. * * @return HasMany */ public function polls(): HasMany { return $this->hasMany(Poll::class); } /** * Get the torrents uploaded by the user. * * @return HasMany */ public function torrents(): HasMany { return $this->hasMany(Torrent::class); } /** * Get the playlists created by the user. * * @return HasMany */ public function playlists(): HasMany { return $this->hasMany(Playlist::class); } /** * Get the playlist suggestions created by the user. * * @return HasMany */ public function playlistSuggestions(): HasMany { return $this->hasMany(PlaylistSuggestion::class); } /** * Get the private messages sent by the user. * * @return HasMany */ public function sentPrivateMessages(): HasMany { return $this->hasMany(PrivateMessage::class, 'sender_id'); } /** * Get the peers for the user. * * @return HasMany */ public function peers(): HasMany { return $this->hasMany(Peer::class); } /** * Get the articles authored by the user. * * @return HasMany */ public function articles(): HasMany { return $this->hasMany(Article::class); } /** * Get the topics started by the user. * * @return HasMany */ public function topics(): HasMany { return $this->hasMany(Topic::class, 'first_post_user_id', 'id'); } /** * Get the posts written by the user. * * @return HasMany */ public function posts(): HasMany { return $this->hasMany(Post::class); } /** * Get the comments written by the user. * * @return HasMany */ public function comments(): HasMany { return $this->hasMany(Comment::class); } /** * Get the torrent requests created by the user. * * @return HasMany */ public function requests(): HasMany { return $this->hasMany(TorrentRequest::class); } /** * Get the torrent requests approved by the user. * * @return HasMany */ public function ApprovedRequests(): HasMany { return $this->hasMany(TorrentRequest::class, 'approved_by'); } /** * Get the torrent requests filled by the user. * * @return HasMany */ public function filledRequests(): HasMany { return $this->hasMany(TorrentRequest::class, 'filled_by'); } /** * Get the bounties added by the user. * * @return HasMany */ public function requestBounty(): HasMany { return $this->hasMany(TorrentRequestBounty::class); } /** * Get the torrents moderated by the user. * * @return HasMany */ public function moderated(): HasMany { return $this->hasMany(Torrent::class, 'moderated_by'); } /** * Get the notes written by the user. * * @return HasMany */ public function notes(): HasMany { return $this->hasMany(Note::class, 'user_id'); } /** * Get the reports reported by the user. * * @return HasMany */ public function reports(): HasMany { return $this->hasMany(Report::class, 'reporter_id'); } /** * Get the reports solved by the user. * * @return HasMany */ public function solvedReports(): HasMany { return $this->hasMany(Report::class, 'solved_by'); } /** * Get the torrent history associated with the user. * * @return HasMany */ public function history(): HasMany { return $this->hasMany(History::class, 'user_id'); } /** * Get the bans received by the user. * * @return HasMany */ public function bans(): HasMany { return $this->hasMany(Ban::class, 'owned_by'); } /** * Get the warnings received by the user. * * @return HasMany */ public function warnings(): HasMany { return $this->hasMany(Warning::class, 'user_id'); } /** * Get the invites sent by the user. * * @return HasMany */ public function sentInvites(): HasMany { return $this->hasMany(Invite::class, 'user_id'); } /** * Get the invites received by the user. * * @return HasMany */ public function receivedInvites(): HasMany { return $this->hasMany(Invite::class, 'accepted_by'); } /** * Get the torrents featured by the user. * * @return HasMany */ public function featuredTorrent(): HasMany { return $this->hasMany(FeaturedTorrent::class); } /** * Get the likes created by the user. * * @return HasMany */ public function likes(): HasMany { return $this->hasMany(Like::class); } /** * Get the subscriptions for the user. * * @return HasMany */ public function subscriptions(): HasMany { return $this->hasMany(Subscription::class); } /** * Get the resurrections for the user. * * @return HasMany */ public function resurrections(): HasMany { return $this->hasMany(Resurrection::class); } /** * Get the forums subscribed to by the user. * * @return BelongsToMany */ public function subscribedForums(): BelongsToMany { return $this->belongsToMany(Forum::class, 'subscriptions'); } /** * Get the topics subscribed to by the user. * * @return BelongsToMany */ public function subscribedTopics(): BelongsToMany { return $this->belongsToMany(Topic::class, 'subscriptions'); } /** * Get the forum permissions of the user's group. * * @return HasMany */ public function forumPermissions(): HasMany { return $this->hasMany(ForumPermission::class, 'group_id', 'group_id'); } /** * Get the the freeleech tokens for the user. * * @return HasMany */ public function freeleechTokens(): HasMany { return $this->hasMany(FreeleechToken::class); } /** * Get the tickets for the user. * * @return HasMany */ public function tickets(): HasMany { return $this->hasMany(Ticket::class, 'user_id'); } /** * Get the personal freeleeches for the user. * * @return HasMany */ public function personalFreeleeches(): HasMany { return $this->hasMany(PersonalFreeleech::class); } /** * Get the failed logins for the user. * * @return HasMany */ public function failedLogins(): HasMany { return $this->hasMany(FailedLoginAttempt::class); } /** * Get the upload snatches for the user. * * @return HasManyThrough */ public function uploadSnatches(): HasManyThrough { return $this->hasManyThrough(History::class, Torrent::class)->whereNotNull('completed_at'); } /** * Get the gifts sent by the user. * * @return HasMany */ public function sentGifts(): HasMany { return $this->hasMany(Gift::class, 'sender_id'); } /** * Get the gifts received by the user. * * @return HasMany */ public function receivedGifts(): HasMany { return $this->hasMany(Gift::class, 'recipient_id'); } /** * Get the tips sent by the user. * * @return HasMany */ public function sentPostTips(): HasMany { return $this->hasMany(PostTip::class, 'sender_id'); } /** * Get the post tips received by the user. * * @return HasMany */ public function receivedPostTips(): HasMany { return $this->hasMany(PostTip::class, 'recipient_id'); } /** * Get the torrent tips sent by the user. * * @return HasMany */ public function sentTorrentTips(): HasMany { return $this->hasMany(TorrentTip::class, 'sender_id'); } /** * Get the torrent tips received by the user. * * @return HasMany */ public function receivedTorrentTips(): HasMany { return $this->hasMany(TorrentTip::class, 'recipient_id'); } /** * Get the seedboxes owned by the user. * * @return HasMany */ public function seedboxes(): HasMany { return $this->hasMany(Seedbox::class); } /** * Get the application submitted by the user. * * @return HasOneThrough */ public function application(): HasOneThrough { return $this->hasOneThrough(Application::class, Invite::class, 'accepted_by', 'email', 'id', 'email'); } /** * Get passkeys for the user. * * @return HasMany */ public function passkeys(): HasMany { return $this->hasMany(Passkey::class); } /** * Get the rsskeys for the user. * * @return HasMany */ public function rsskeys(): HasMany { return $this->hasMany(Rsskey::class); } /** * Get the apikeys for the user. * * @return HasMany */ public function apikeys(): HasMany { return $this->hasMany(Apikey::class); } /** * Get the email updates for the user. * * @return HasMany */ public function emailUpdates(): HasMany { return $this->hasMany(EmailUpdate::class); } /** * Get the password reset history for the user. * * @return HasMany */ public function passwordResetHistories(): HasMany { return $this->hasMany(PasswordResetHistory::class); } /** * Get the torrent trumps for the user. * * @return HasMany */ public function torrentTrumps(): HasMany { return $this->hasMany(TorrentTrump::class); } /** * Get the audits created by the user. * * @return HasMany */ public function audits(): HasMany { return $this->hasMany(Audit::class); } /** * Get the prizes claimed by the user. * * @return HasMany */ public function claimedPrizes(): HasMany { return $this->hasMany(ClaimedPrize::class); } /** * Get the donations submitted by the user. * * @return HasMany */ public function donations(): HasMany { return $this->hasMany(Donation::class); } /** * Get the conversations participated in by the user. * * @return BelongsToMany */ public function conversations(): BelongsToMany { return $this->belongsToMany(Conversation::class, 'participants'); } /** * Get the participations of the user in conversations. * * @return HasMany */ public function participations(): HasMany { return $this->hasMany(Participant::class); } /** * Get the user's notification acceptance as bool. */ public function acceptsNotification(self $sender, self $target, string $group = 'follower', bool|string $type = false): bool { $targetGroup = 'json_'.$group.'_groups'; if ($sender->id === $target->id) { return false; } if ($sender->group->is_modo || $sender->group->is_admin) { return true; } if ($target->notification?->block_notifications == 1) { return false; } if ($target->notification && $type && (!$target->notification->$type)) { return false; } if (\is_array($target->notification?->$targetGroup)) { return !\in_array($sender->group->id, $target->notification->$targetGroup, true); } return true; } /** * Get the user's privacy hidden as bool. */ public function isVisible(self $target, string $group = 'profile', bool|string $type = false): bool { $targetGroup = 'json_'.$group.'_groups'; $sender = auth()->user(); if ($sender->id == $target->id) { return true; } if ($sender->group->is_modo || $sender->group->is_admin) { return true; } if ($target->privacy?->getAttribute('hidden')) { return false; } if ($target->privacy && $type && (!$target->privacy->$type || $target->privacy->$type == 0)) { return false; } if (\is_array($target->privacy?->$targetGroup)) { return !\in_array($sender->group->id, $target->privacy->$targetGroup); } return true; } /** * Get the user's privacy visibility as bool. */ public function isAllowed(self $target, string $group = 'profile', bool|string $type = false): bool { $targetGroup = 'json_'.$group.'_groups'; $sender = auth()->user(); if ($sender->id == $target->id) { return true; } if ($sender->group->is_modo || $sender->group->is_admin) { return true; } if ($target->privacy?->private_profile == 1) { return false; } if ($target->privacy && $type && (!$target->privacy->$type || $target->privacy->$type == 0)) { return false; } if (\is_array($target->privacy?->$targetGroup)) { return !\in_array($sender->group->id, $target->privacy->$targetGroup); } return true; } /** * Get upload in human format. */ public function getFormattedUploadedAttribute(): string { $bytes = $this->uploaded; if ($bytes > 0) { return StringHelper::formatBytes((float) $bytes, 2); } return StringHelper::formatBytes(0, 2); } /** * Get download in human format. */ public function getFormattedDownloadedAttribute(): string { $bytes = $this->downloaded; if ($bytes > 0) { return StringHelper::formatBytes((float) $bytes, 2); } return StringHelper::formatBytes(0, 2); } /** * Get the ratio. */ public function getRatioAttribute(): float { if ($this->downloaded === 0) { return INF; } return round($this->uploaded / $this->downloaded, 2); } /** * Get ratio in human format. */ public function getFormattedRatioAttribute(): string { $ratio = $this->ratio; if (is_infinite($ratio)) { return '∞'; } return (string) $ratio; } /** * Return the size (pretty formatted) which can be safely downloaded * without falling under the minimum ratio. */ public function getFormattedBufferAttribute(): string { if (config('other.ratio') === 0) { return '∞'; } $bytes = round(($this->uploaded / config('other.ratio')) - $this->downloaded); return StringHelper::formatBytes($bytes); } /** * Get the formatted bonus points of the user. */ public function getFormattedSeedbonusAttribute(): string { return number_format((float) $this->seedbonus, 0, null, "\u{202F}"); } /** * Make sure that password reset emails are sent after the user has sent a * password reset request, that way an attacker can't use the timing to * determine if an email was sent or not. * * @param $token * @return void */ public function sendPasswordResetNotification($token): void { dispatch(fn () => $this->notify(new ResetPassword($token)))->afterResponse(); } }