delete_multi([ sprintf(self::CACHE_KEY, $this->id), sprintf('user_inv_pending_%d', $this->id), sprintf('user_invited_%d', $this->id), sprintf('user_last_access_%d', $this->id), sprintf('user_siteip_count_%d', $this->id), sprintf('user_stat_%d', $this->id), sprintf('users_tokens_%d', $this->id), ]); $this->stats()->flush(); $this->ordinal()->flush(); $this->privilege()->flush(); unset($this->info, $this->ordinal, $this->privilege, $this->stats, $this->tokenCache); return $this; } public function link(): string { return sprintf('%s', $this->url(), html_escape($this->username())); } public function location(): string { return 'user.php?id=' . $this->id; } /** * Delegate privilege methods to the User\AuditTrail class * This delegation is stateful. */ public function auditTrail(): User\AuditTrail { return $this->auditTrail ??= new User\AuditTrail($this); } /** * Delegate snatch status methods to the User\Inbox class. * A new object is instantiated each time. This is nearly * always what you need, if just creating a new conversation. */ public function inbox(): User\Inbox { return new User\Inbox($this); } public function invite(): User\Invite { return $this->invite ??= new User\Invite($this); } public function ordinal(): User\Ordinal { return $this->ordinal ??= new User\Ordinal($this); } public function privilege(): User\Privilege { return $this->privilege ??= new User\Privilege($this); } public function snatch(): User\Snatch { return $this->snatch ??= new User\Snatch($this); } public function stats(): \Gazelle\Stats\User { return $this->stats ??= new Stats\User($this->id); } /** * Log out the current session */ public function logout($sessionId = false): void { setcookie('session', '', [ 'expires' => time() - 60 * 60 * 24 * 90, 'path' => '/', 'secure' => !DEBUG_MODE, 'httponly' => true, 'samesite' => 'Strict', ]); if ($sessionId) { (new User\Session($this))->drop($sessionId); } $this->flush(); } /** * Logout all sessions */ public function logoutEverywhere(): void { $session = new User\Session($this); $session->dropAll(); $this->logout(); } public function info(): array { if (isset($this->info)) { return $this->info; } $key = sprintf(self::CACHE_KEY, $this->id); $info = self::$cache->get_value($key); if ($info !== false) { return $this->info = $info; } $qid = self::$db->get_query_id(); self::$db->prepared_query(" SELECT um.Username, um.auth_key, um.avatar, um.can_leech, um.collage_total, um.created, um.inviter_user_id, um.IP, um.Email, um.Enabled, um.Invites, um.IRCKey, um.nav_list, um.Paranoia, um.PassHash, um.PermissionID, um.profile_info, um.profile_title, um.RequiredRatio, um.slogan, um.Title, um.torrent_pass, um.Visible, um.2FA_Key, ui.AdminComment, ui.BanDate, ui.NavItems, ui.RatioWatchEnds, ui.RatioWatchDownload, ui.SiteOptions, uls.Uploaded, uls.Downloaded, p.Level AS Class, p.Name AS className, if(p.Level >= (SELECT Level FROM permissions WHERE ID = ?), 1, 0) as isStaff, uf.tokens AS FLTokens, coalesce(ub.points, 0) AS BonusPoints, la.Type as locked_account FROM users_main AS um INNER JOIN users_leech_stats AS uls ON (uls.UserID = um.ID) INNER JOIN users_info AS ui ON (ui.UserID = um.ID) INNER JOIN user_flt AS uf ON (uf.user_id = um.ID) LEFT JOIN permissions AS p ON (p.ID = um.PermissionID) LEFT JOIN user_bonus AS ub ON (ub.user_id = um.ID) LEFT JOIN locked_accounts AS la ON (la.UserID = um.ID) WHERE um.ID = ? ", FORUM_MOD, $this->id ); $this->info = self::$db->next_record(MYSQLI_ASSOC, false) ?? []; self::$db->set_query_id($qid); if (empty($this->info)) { return $this->info; } $this->info['CommentHash'] = sha1($this->info['AdminComment']); $this->info['nav_list'] = json_decode($this->info['nav_list'] ?? '[]', true); $this->info['NavItems'] = empty($this->info['NavItems']) ? [] : explode(',', $this->info['NavItems']); $this->info['ParanoiaRaw'] = $this->info['Paranoia']; $this->info['Paranoia'] = $this->info['Paranoia'] ? unserialize($this->info['Paranoia']) : []; $this->info['SiteOptions'] = $this->info['SiteOptions'] ? unserialize($this->info['SiteOptions']) : []; if (!isset($this->info['SiteOptions']['HttpsTracker'])) { $this->info['SiteOptions']['HttpsTracker'] = true; } $this->info['RatioWatchEndsEpoch'] = $this->info['RatioWatchEnds'] ? strtotime($this->info['RatioWatchEnds']) : 0; self::$db->prepared_query(" SELECT ua.Name, ua.ID FROM user_attr ua INNER JOIN user_has_attr uha ON (uha.UserAttrID = ua.ID) WHERE uha.UserID = ? ", $this->id ); $this->info['attr'] = self::$db->to_pair('Name', 'ID', false); $this->info['warning_expiry'] = (new User\Warning($this))->warningExpiry(); self::$cache->cache_value($key, $this->info, 3600); self::$db->set_query_id($qid); return $this->info; } /** * Get the custom user link navigation configuration. */ public function navigationList(): array { return $this->info()['NavItems']; } public function addCustomPrivilege(string $name): bool { $custom = (string)self::$db->scalar(" SELECT CustomPermissions FROM users_main WHERE ID = ? ", $this->id ); $custom = unserialize($custom) ?: []; $custom[$name] = 1; $this->privilege()->flush(); return $this->setField('CustomPermissions', serialize($custom))->modify(); } /** * Set the custom permissions for this user * TODO: this is pretty messed up, make it nice (get rid if "perm_") * * @param array $current a list of "perm_" custom permissions * @return bool was there a change? */ public function modifyPrivilegeList(array $current): bool { $permissionList = array_keys(\Gazelle\Manager\Privilege::privilegeList()); $default = $this->privilege()->defaultPrivilegeList(); $delta = []; foreach ($permissionList as $p) { $new = isset($current["perm_$p"]) ? 1 : 0; $old = isset($default[$p]) ? 1 : 0; if ($new != $old) { $delta[$p] = $new; } } self::$db->prepared_query(" UPDATE users_main SET CustomPermissions = ? WHERE ID = ? ", count($delta) ? serialize($delta) : null, $this->id ); $affected = self::$db->affected_rows(); $this->privilege()->flush(); return $affected === 1; } /** * Does the user have a specific privilege? */ public function permitted(string $privilege): bool { return $this->privilege()->permitted($privilege); } /** * Does the user have any of the specified privileges? */ public function permittedAny(string ...$privilege): bool { foreach ($privilege as $p) { if ($this->privilege()->permitted($p)) { return true; } } return false; } public function hasAttr(string $name): bool { return isset($this->info()['attr'][$name]); } public function toggleAttr(string $attr, bool $flag): bool { $hasAttr = $this->hasAttr($attr); $toggled = false; if (!$flag && $hasAttr) { self::$db->prepared_query(" DELETE FROM user_has_attr WHERE UserID = ? AND UserAttrID = (SELECT ID FROM user_attr WHERE Name = ?) ", $this->id, $attr ); $toggled = self::$db->affected_rows() === 1; } elseif ($flag && !$hasAttr) { self::$db->prepared_query(" INSERT INTO user_has_attr (UserID, UserAttrID) SELECT ?, ID FROM user_attr WHERE Name = ? ", $this->id, $attr ); $toggled = self::$db->affected_rows() === 1; } if ($toggled) { $this->flush(); } return $toggled; } /** * toggle Unlimited Download setting */ public function toggleUnlimitedDownload(bool $flag): bool { return $this->toggleAttr('unlimited-download', $flag); } public function hasUnlimitedDownload(): bool { return $this->hasAttr('unlimited-download'); } /** * toggle Accept FL token setting * If user accepts FL tokens and the refusal attribute is found, delete it. * If user refuses FL tokens and the attribute is not found, insert it. */ public function toggleAcceptFL($flag): bool { return $this->toggleAttr('no-fl-gifts', !$flag); } public function hasAcceptFL(): bool { return !$this->hasAttr('no-fl-gifts'); } public function announceKey(): string { return $this->info()['torrent_pass']; } public function announceUrl(): string { return ($this->info()['SiteOptions']['HttpsTracker'] ? ANNOUNCE_HTTPS_URL : ANNOUNCE_HTTP_URL) . '/' . $this->announceKey() . '/announce'; } public function auth(): string { return $this->info()['auth_key']; } public function avatar(): ?string { return $this->info()['avatar']; } public function avatarMode(): AvatarDisplay { return match ((int)$this->option('DisableAvatars')) { AvatarDisplay::none->value => AvatarDisplay::none, AvatarDisplay::fallbackSynthetic->value => AvatarDisplay::fallbackSynthetic, AvatarDisplay::forceSynthetic->value => AvatarDisplay::forceSynthetic, default => AvatarDisplay::show, }; } /** * Assemble the pieces needed to display a user avatar * - an avatar (or the site default, or an sythetic image based on the username) * - optional hover text for donors * - optional rollover avatar for donors * Twig will use these pieces to construct the markup for their avatar. */ public function avatarComponentList(User $viewed): array { $viewedId = $viewed->id(); if (!isset($this->avatarCache[$viewedId])) { $donor = new User\Donor($viewed); $this->avatarCache[$viewedId] = [ 'image' => match ($this->avatarMode()) { AvatarDisplay::show => $viewed->avatar() ?: USER_DEFAULT_AVATAR, AvatarDisplay::fallbackSynthetic => $viewed->avatar() ?: (new User\SyntheticAvatar($this))->avatar($viewed->username()), AvatarDisplay::forceSynthetic => (new User\SyntheticAvatar($this))->avatar($viewed->username()), AvatarDisplay::none => USER_DEFAULT_AVATAR, /** @phpstan-ignore-line */ }, 'hover' => $donor->avatarHover(), 'text' => $donor->avatarHoverText(), ]; } return $this->avatarCache[$viewedId]; } public function banDate(): ?string { return $this->info()['BanDate']; } public function bonusPointsTotal(): int { return (int)$this->info()['BonusPoints']; } /** * Is a user allowed to download a torrent file? */ public function canLeech(): bool { return $this->info()['can_leech']; } public function classLevel(): int { return $this->info()['Class']; } public function created(): string { return $this->info()['created']; } public function disableAvatar(): bool { return $this->hasAttr('disable-avatar'); } public function disableBonusPoints(): bool { return $this->hasAttr('disable-bonus-points'); } public function disableForums(): bool { return $this->hasAttr('disable-forums'); } public function disableInvites(): bool { return $this->hasAttr('disable-invites'); } public function disableIRC(): bool { return $this->hasAttr('disable-irc'); } public function disablePm(): bool { return $this->hasAttr('disable-pm'); } public function disablePosting(): bool { return $this->hasAttr('disable-posting'); } public function disableRequests(): bool { return $this->hasAttr('disable-requests'); } public function disableTagging(): bool { return $this->hasAttr('disable-tagging'); } public function disableUpload(): bool { return $this->hasAttr('disable-upload'); } public function disableWiki(): bool { return $this->hasAttr('disable-wiki'); } public function downloadAsText(): bool { return $this->hasAttr('download-as-text'); } public function downloadedSize(): int { return $this->info()['Downloaded']; } public function downloadSpeed(): float { $createdEpoch = strtotime($this->created()); if ($createdEpoch === false) { return 0.0; } $delta = (time() - $createdEpoch); return $delta !== 0 ? $this->downloadedSize() / $delta : 0; } public function downloadedOnRatioWatch(): int { return $this->info()['RatioWatchDownload']; } public function email(): string { return $this->info()['Email']; } public function externalProfile(): User\ExternalProfile { return new User\ExternalProfile($this); } public function ipaddr(): string { return $this->info()['IP']; } public function IRCKey(): ?string { return $this->info()['IRCKey']; } public function label(): string { return $this->id . " (" . $this->info()['Username'] . ")"; } public function lastAccess(): ?string { $lastAccess = $this->getSingleValue('user_last_access', " SELECT ula.last_access FROM user_last_access ula WHERE user_id = ? "); return $lastAccess ?: null; } public function lastAccessRealtime(): ?string { $lastAccess = self::$db->scalar(" SELECT coalesce(max(ulad.last_access), ula.last_access) FROM user_last_access ula LEFT JOIN user_last_access_delta ulad USING (user_id) WHERE ula.user_id = ? GROUP BY ula.user_id ", $this->id ); return $lastAccess ? (string)$lastAccess : null; } public function option(string $option): mixed { return $this->info()['SiteOptions'][$option] ?? null; } public function postsPerPage(): int { return $this->info()['SiteOptions']['PostsPerPage'] ?? POSTS_PER_PAGE; } public function profileInfo(): string { return $this->info()['profile_info']; } public function profileTitle(): string { return $this->info()['profile_title'] ?: 'Profile'; } public function requestCreationInfo(): array { return byte_format_array($this->requestCreationValue()); } public function requestCreationValue(): int { return $this->ordinal()->value('request-bounty-create'); } public function requestVoteInfo(): array { return byte_format_array($this->requestVoteValue()); } public function requestVoteValue(): int { return $this->ordinal()->value('request-bounty-vote'); } public function requiredRatio(): float { return $this->info()['RequiredRatio']; } public function rssAuth(): string { return md5($this->id . RSS_HASH . $this->announceKey()); } public function showAvatars(): bool { return $this->avatarMode() != AvatarDisplay::none; } public function slogan(): ?string { return $this->info()['slogan']; } public function staffNotes(): string { return $this->info()['AdminComment']; } public function TFAKey(): ?string { return $this->info()['2FA_Key']; } public function title(): ?string { return $this->info()['Title']; } public function uploadedSize(): int { return $this->info()['Uploaded']; } public function uploadSpeed(): float { $createdEpoch = strtotime($this->created()); if ($createdEpoch === false) { return 0.0; } $delta = (time() - $createdEpoch); return $delta !== 0 ? ($this->uploadedSize() - STARTING_UPLOAD) / $delta : 0; } public function userclassName(): string { return $this->info()['className']; } public function username(): string { return $this->info()['Username']; } public function userStatus(): UserStatus { return match ($this->info()['Enabled']) { '1' => UserStatus::enabled, '2' => UserStatus::disabled, default => UserStatus::unconfirmed, }; } /** * Create the recovery keys for the user */ public function create2FA(Manager\UserToken $manager, string $key): int { $unique = []; while (count($unique) < 10) { $unique[randomString(20)] = 1; } $recovery = array_keys($unique); self::$db->prepared_query(" UPDATE users_main SET 2FA_Key = ?, Recovery = ? WHERE ID = ? ", $key, serialize($recovery), $this->id ); $affected = self::$db->affected_rows(); foreach ($recovery as $value) { $manager->create(UserTokenType::mfa, user: $this, value: $value); } $this->auditTrail()->addEvent(UserAuditEvent::mfa, 'configured'); $this->flush(); return $affected; } public function list2FA(): array { return unserialize((string)self::$db->scalar(" SELECT Recovery FROM users_main WHERE ID = ? ", $this->id )) ?: []; } /** * A user is attempting to login with 2FA via a recovery key * If we have the key on record, burn it and let them in. * * @param string $key Recovery key from user * @return bool Valid key, they may log in. */ public function burn2FARecovery(string $key): bool { $list = $this->list2FA(); $index = array_search($key, $list); if ($index === false) { return false; } unset($list[$index]); self::$db->prepared_query(' UPDATE users_main SET Recovery = ? WHERE ID = ? ', count($list) === 0 ? null : serialize($list), $this->id ); $burnt = self::$db->affected_rows() === 1; if ($burnt) { $this->auditTrail()->addEvent(UserAuditEvent::mfa, "used token $key"); } return $burnt; } public function remove2FA(): static { $this->auditTrail()->addEvent(UserAuditEvent::mfa, "removed"); return $this->setField('2FA_Key', null) ->setField('Recovery', null); } public function paranoia(): array { return $this->info()['Paranoia']; } public function isParanoid(string $for): bool { return in_array($for, $this->info()['Paranoia']); } public function paranoiaLevel(): int { $paranoia = $this->paranoia(); $level = count($paranoia); foreach ($paranoia as $p) { if (str_ends_with($p, '+')) { $level++; } } return $level; } public function paranoiaLabel(): string { $level = $this->paranoiaLevel(); return match (true) { ($level > 20) => 'Very high', ($level > 5) => 'High', ($level > 1) => 'Low', ($level == 1) => 'Very Low', default => 'Off', }; } // The following are used throughout the site: // uploaded, ratio, downloaded: stats // lastseen: approximate time the user last used the site // uploads: the full list of the user's uploads // uploads+: just how many torrents the user has uploaded // snatched, seeding, leeching: the list of the user's snatched torrents, seeding torrents, and leeching torrents respectively // snatched+, seeding+, leeching+: the length of those lists respectively // uniquegroups, perfectflacs: the list of the user's uploads satisfying a particular criterion // uniquegroups+, perfectflacs+: the length of those lists // If "uploads+" is disallowed, so is "uploads". So if "uploads" is in the array, the user is a little paranoid, "uploads+", very paranoid. // The following are almost only used in /sections/user/user.php: // requiredratio // requestsfilled_count: the number of requests the user has filled // requestsfilled_bounty: the bounty thus earned // requestsfilled_list: the actual list of requests the user has filled // requestsvoted_...: similar // artistsadded: the number of artists the user has added // torrentcomments: the list of comments the user has added to torrents // + // collages: the list of collages the user has created // + // collagecontribs: the list of collages the user has contributed to // + // invitedcount: the number of users this user has directly invited /** * Return whether currently logged in user can see $Property on a user with $Paranoia, $UserClass and (optionally) $UserID * If $Property is an array of properties, returns whether currently logged in user can see *all* $Property ... * * @param $Property The property to check, or an array of properties. * @param $Paranoia The paranoia level to check against. * @param $UserClass The user class to check against (Staff can see through paranoia of lower classed staff) * @param $UserID Optional. The user ID of the person being viewed * @return mixed 1 representing the user has normal access 2 representing that the paranoia was overridden, false representing access denied. */ /** * What right does the viewer have to see a list of properties of this user? * * returns PARANOIA_HIDE, PARANOIA_OVERRIDDEN, PARANOIA_ALLOWED */ public function propertyVisibleMulti(User $viewer, array $propertyList): int { $paranoia = array_map(fn($p) => $this->propertyVisible($viewer, $p), $propertyList); if (in_array(PARANOIA_HIDE, $paranoia)) { return PARANOIA_HIDE; } return in_array(PARANOIA_OVERRIDDEN, $paranoia) ? PARANOIA_OVERRIDDEN : PARANOIA_ALLOWED; } /** * What right does the viewer have to see a property of this user? * * returns PARANOIA_HIDE, PARANOIA_OVERRIDDEN, PARANOIA_ALLOWED */ public function propertyVisible(User $viewer, string $property): int { if ($this->id === $viewer->id()) { return PARANOIA_ALLOWED; } $paranoia = $this->paranoia(); if (!in_array($property, $paranoia) && !in_array("$property+", $paranoia)) { return PARANOIA_ALLOWED; } if ($viewer->permitted('users_override_paranoia') || $viewer->permitted(PARANOIA_OVERRIDE[$property] ?? '')) { return PARANOIA_OVERRIDDEN; } return PARANOIA_HIDE; } public function ratioWatchExpiry(): ?string { return $this->info()['RatioWatchEnds']; } public function recoveryFinalSize(): ?float { if (RECOVERY_DB) { return (float)self::$db->scalar(" SELECT final FROM recovery_buffer WHERE user_id = ? ", $this->id ); } return null; } public function primaryClass(): int { return $this->info()['PermissionID']; } /** * Checks whether user has autocomplete enabled * * @param string $Type Where is the input requested (search, other) */ public function hasAutocomplete(string $Type): bool { $autoComplete = $this->option('AutoComplete'); if (is_null($autoComplete)) { // not set, default to enabled return true; } elseif ($autoComplete == 1) { // disabled return false; } elseif ($Type === 'search' && $autoComplete != 1) { return true; } elseif ($Type === 'other' && $autoComplete != 2) { return true; } return false; } /** * Return the list for forum IDs to which the user has been banned. * (Note that banning takes precedence of permitting). */ public function forbiddenForums(): array { return $this->privilege()->forbiddenForumIdList(); } /** * Return the list for forum IDs to which the user has been granted special access. */ public function permittedForums(): array { return $this->privilege()->permittedForumIdList(); } public function forbiddenForumsList(): string { return $this->privilege()->forbiddenForums(); } public function permittedForumsList(): string { return $this->privilege()->permittedForums(); } /** * Checks whether user has any overrides to a forum * * @return bool has access */ public function forumAccess(int $forumId, int $forumMinClassLevel): bool { return ($this->classLevel() >= $forumMinClassLevel || in_array($forumId, $this->permittedForums())) && !in_array($forumId, $this->forbiddenForums()); } /** * Checks whether user has the permission to create a forum. * * @return boolean true if user has permission */ public function createAccess(Forum $forum): bool { return $this->forumAccess($forum->id(), $forum->minClassCreate()); } /** * Checks whether user has the permission to read a forum. * * @return boolean true if user has permission */ public function readAccess(Forum $forum): bool { return $this->forumAccess($forum->id(), $forum->minClassRead()); } /** * Checks whether user has the permission to write to a forum. * * @return boolean true if user has permission */ public function writeAccess(Forum $forum): bool { return $this->forumAccess($forum->id(), $forum->minClassWrite()); } /** * Checks whether the user is up to date on the forum * * @return bool the user is up to date */ public function hasReadLastPost(Forum $forum): bool { return $forum->isLocked() || $this->lastReadInThread($forum->lastThreadId()) >= $forum->lastPostId() || $this->forumCatchupEpoch() >= $forum->lastPostEpoch(); } /** * What is the last post this user has read in a thread? */ public function lastReadInThread(int $threadId): int { if (!isset($this->lastRead)) { self::$db->prepared_query(" SELECT TopicID, PostID FROM forums_last_read_topics WHERE UserID = ? ", $this->id ); $this->lastRead = self::$db->to_pair('TopicID', 'PostID', false); } return $this->lastRead[$threadId] ?? 0; } /** * When did the user last perform a global catchup on the forums? * * @return int epoch of catchup */ public function forumCatchupEpoch() { if (!isset($this->lastReadForum)) { $this->lastReadForum = (int)self::$db->scalar(" SELECT unix_timestamp(last_read) FROM user_read_forum WHERE user_id = ? ", $this->id ); } return $this->lastReadForum; } public function forumLastReadList(int $perPage, Forum $forum): array { self::$db->prepared_query(" SELECT l.TopicID AS thread_id, l.PostID AS post_id, ceil((SELECT count(*) FROM forums_posts AS p WHERE p.TopicID = l.TopicID AND p.ID <= l.PostID) / ?) AS page FROM forums_last_read_topics AS l INNER JOIN forums_topics ft ON (ft.ID = l.TopicID) INNER JOIN forums f ON (f.ID = ft.ForumID) WHERE l.UserID = ? AND f.ID = ? ", $perPage, $this->id, $forum->id() ); $list = []; foreach (self::$db->to_array('thread_id', MYSQLI_ASSOC, false) as $row) { $row['page'] = (int)$row['page']; $list[$row['thread_id']] = $row; } return $list; } public function forceCacheFlush($flush = true): bool { return $this->forceCacheFlush = $flush; } public function flushRecentUpload(): bool { return self::$cache->delete_value(sprintf(self::USER_RECENT_UPLOAD, $this->id)); } public function remove(): int { $id = $this->id; $username = $this->username(); // Many, but not all, of the associated user tables will drop their entries via foreign key cascades. // But some won't. If this call fails, you will need to decide what to do about the tables in question. self::$db->prepared_query(" DELETE FROM users_main WHERE ID = ? ", $id ); $affected = self::$db->affected_rows(); $this->flush(); self::$cache->delete_multi([ sprintf(Manager\User::ID_KEY, $id), sprintf(Manager\User::USERNAME_KEY, $username), ]); return $affected; } /** * Record a forum warning for this user */ public function addForumWarning(string $reason): static { $this->forumWarning[] = $reason; return $this; } /** * Record a staff note for this user */ public function addStaffNote(string $note): static { $this->staffNote[] = $note; return $this; } /** * Set the user custom title (may contain BBcode) */ public function setTitle(string $title): bool { $title = trim($title); if (mb_strlen($title) > USER_TITLE_LENGTH) { return false; } $this->setField('Title', $title); return true; } /** * Remove the custom title of a user */ public function removeTitle(): static { return $this->setField('Title', null); } /** * Warn a user. Returns expiry date. */ public function warn(int $duration, string $reason, \Gazelle\User $staff, string $userMessage): string { $warnTime = Time::offset($duration * 7 * 86_400); $warning = new \Gazelle\User\Warning($this); $expiry = $warning->warningExpiry(); if ($expiry) { $subject = 'You have received a new warning'; $message = "You have received a new warning by [user]{$staff->username()}[/user]. " . "You had an existing warning (set to expire at $expiry).\n\nDue to this prior warning, " . "you will remain warned until $warnTime.\nReason: $userMessage"; } else { $subject = 'You have been warned'; $message = "You have been warned by [user]{$staff->username()}[/user]. " . "The warning is set to expire on $warnTime. Remember, repeated warnings may jeopardize " . "your account.\nReason: $userMessage"; } $this->inbox()->createSystem($subject, $message); return $warning->add($reason, "$duration week" . plural($duration), $staff); } /** * Issue a warning for a comment or forum post */ public function warnPost( BaseObject $post, int $weekDuration, \Gazelle\User $staffer, string $staffReason, string $userMessage ): void { if (!$weekDuration) { // verbal warning $warned = "Verbally warned"; $this->inbox()->createSystem( "You have received a verbal warning", "You have received a verbal warning by [user]{$staffer->username()}[/user] for {$post->publicLocation()}.\n\n[quote]{$userMessage}[/quote]" ); } else { $message = "for {$post->publicLocation()}.\n\n[quote]{$userMessage}[/quote]"; $expiry = $this->warn($weekDuration, "{$post->publicLocation()} - $staffReason", $staffer, $message); $warned = "Warned until $expiry"; } $this->addForumWarning("$warned by {$staffer->username()} for {$post->publicLocation()}\nReason: $staffReason") ->modify(); } public function modifyOption(string $name, $value): static { $options = $this->info()['SiteOptions']; if (is_null($value)) { unset($options[$name]); } else { $options[$name] = $value; } self::$db->prepared_query(" UPDATE users_info SET SiteOptions = ? WHERE UserID = ? ", serialize($options), $this->id ); $this->flush(); return $this; } public function modify(): bool { $changed = false; if (!empty($this->forumWarning)) { $warning = implode(', ', $this->forumWarning); self::$db->prepared_query(" INSERT INTO users_warnings_forums (UserID, Comment) VALUES (?, concat(now(), ' - ', ?)) ON DUPLICATE KEY UPDATE Comment = concat(Comment, '\n', now(), ' - ', ?) ", $this->id, $warning, $warning ); $changed = self::$db->affected_rows() > 0; // 1 or 2 depending on whether the update is triggered $this->forumWarning = []; self::$cache->delete_value('user_forum_warn_' . $this->id); } if (!empty($this->staffNote)) { self::$db->prepared_query(" UPDATE users_info SET AdminComment = CONCAT(now(), ' - ', ?, AdminComment) WHERE UserID = ? ", implode(', ', $this->staffNote) . "\n\n", $this->id ); $changed = $changed || self::$db->affected_rows() === 1; $this->staffNote = []; } $userInfo = []; if ($this->field('nav_list') !== null) { $userInfo['NavItems = ?'] = implode(',', $this->field('nav_list')); $this->setField('nav_list', json_encode($this->clearField('nav_list'))); } if ($this->field('option_list') !== null) { $userInfo['SiteOptions = ?'] = serialize($this->clearField('option_list')); // remove field } $now = []; foreach (['BanDate', 'RatioWatchEnds'] as $field) { if ($this->nowField($field)) { // the value is the field, this came from $nowField $now[] = "{$this->clearField($field)} = now()"; } } foreach (['AdminComment', 'BanDate', 'BanReason', 'PermittedForums', 'RestrictedForums', 'RatioWatchDownload', 'RatioWatchEnds'] as $field) { if ($this->field($field) !== null || $this->nullField($field)) { $userInfo["$field = ?"] = $this->clearField($field); } } if ($userInfo || $now) { $columns = implode(', ', [...array_keys($userInfo), ...$now]); self::$db->prepared_query(" UPDATE users_info SET $columns WHERE UserID = ? ", ...[...array_values($userInfo), $this->id] ); $changed = $changed || self::$db->affected_rows() === 1; } $leech = []; if ($this->field('leech_upload') !== null) { $leech['Uploaded = ?'] = $this->clearField('leech_upload'); } if ($this->field('leech_download') !== null) { $leech['Downloaded = ?'] = $this->clearField('leech_download'); } if ($leech) { $columns = implode(', ', array_keys($leech)); self::$db->prepared_query(" UPDATE users_leech_stats SET $columns WHERE UserID = ? ", ...[...array_values($leech), $this->id] ); $changed = $changed || self::$db->affected_rows() === 1; } if ($this->field('lock-type') !== null) { $lockType = $this->clearField('lock-type'); if (!$lockType) { self::$db->prepared_query(" DELETE FROM locked_accounts WHERE UserID = ? ", $this->id ); } else { self::$db->prepared_query(" INSERT INTO locked_accounts (UserID, Type) VALUES (?, ?) ON DUPLICATE KEY UPDATE Type = ? ", $this->id, $lockType, $lockType ); } $changed = $changed || self::$db->affected_rows() === 1; } if (parent::modify() || $changed) { $this->flush(); // parent::modify() may have done a flush() but it's too much code to optimize this second call away return true; } return false; } public function mergeLeechStats(string $username, string $staffname): ?array { [$mergeId, $up, $down] = self::$db->row(" SELECT um.ID, uls.Uploaded, uls.Downloaded FROM users_main um INNER JOIN users_leech_stats AS uls ON (uls.UserID = um.ID) WHERE um.Username = ? ", $username ); if (!$mergeId) { return null; } self::$db->prepared_query(" UPDATE users_leech_stats uls INNER JOIN users_info ui USING (UserID) SET uls.Uploaded = 0, uls.Downloaded = 0, ui.AdminComment = concat(now(), ' - ', ?, ui.AdminComment) WHERE uls.UserID = ? ", sprintf("leech stats (up: %s, down: %s, ratio: %s) transferred to %s (%s) by %s\n\n", byte_format($up), byte_format($down), ratio($up, $down), $this->url(), $this->username(), $staffname ), $mergeId ); $this->flush(); return ['up' => $up, 'down' => $down, 'userId' => $mergeId]; } public function lockType(): ?int { return $this->info()['locked_account']; } public function updateTokens(int $n): bool { self::$db->prepared_query(' UPDATE user_flt SET tokens = ? WHERE user_id = ? ', $n, $this->id ); $this->flush(); return self::$db->affected_rows() === 1; } /** * Validate a user password * * @param string $plaintext password * @return bool true on correct password */ public function validatePassword(#[\SensitiveParameter] string $plaintext): bool { $hash = $this->info()['PassHash']; $success = password_verify(hash('sha256', $plaintext), $hash); if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { self::$db->prepared_query(" UPDATE users_main SET PassHash = ? WHERE ID = ? ", UserCreator::hashPassword($plaintext), $this->id ); } return $success; } /** * Set a new user password. Requires calling modify() to persist new password. */ public function updatePassword(#[\SensitiveParameter] string $pw, bool $notify): static { $this->setField('PassHash', UserCreator::hashPassword($pw)); $ipaddr = $this->requestContext()->remoteAddr(); $useragent = $this->requestContext()->useragent(); self::$db->prepared_query(" INSERT INTO users_history_passwords (UserID, ChangerIP, useragent) VALUES (?, ?, ?) ", $this->id, $ipaddr, $useragent ); self::$cache->delete_value('user_pw_count_' . $this->id); if ($notify) { Irc::sendMessage( $this->username(), "Security alert: Your password was changed via $ipaddr with $useragent" ); (new Mail())->send( $this->email(), 'Password changed information for ' . SITE_NAME, self::$twig->render('email/password-change.twig', [ 'ipaddr' => $ipaddr, 'now' => date('Y-m-d H:i:s'), 'useragent' => $useragent, 'username' => $this->username(), ]) ); } return $this; } public function passwordHistory(): array { self::$db->prepared_query(" SELECT ChangeTime AS date, ChangerIP AS ipaddr, useragent FROM users_history_passwords WHERE UserID = ? ORDER BY ChangeTime DESC ", $this->id ); return self::$db->to_array(false, MYSQLI_ASSOC, false); } public function onRatioWatch(): bool { return $this->info()['RatioWatchEndsEpoch'] !== false && time() > $this->info()['RatioWatchEndsEpoch'] && $this->uploadedSize() <= $this->downloadedSize() * $this->requiredRatio(); } public function modifyAnnounceKeyHistory(string $oldPasskey, string $newPasskey): int { self::$db->prepared_query(" INSERT INTO users_history_passkeys (UserID, OldPassKey, NewPassKey, ChangerIP) VALUES (?, ?, ?, ?) ", $this->id, $oldPasskey, $newPasskey, $this->requestContext()->remoteAddr() ); $affected = self::$db->affected_rows(); self::$cache->delete_value("user_passkey_count_{$this->id}"); return $affected; } public function announceKeyHistory(): array { self::$db->prepared_query(" SELECT OldPassKey AS old, NewPassKey AS new, ChangeTime AS date, ChangerIP AS ipaddr FROM users_history_passkeys WHERE UserID = ? ORDER BY ChangeTime DESC ", $this->id ); return self::$db->to_array(false, MYSQLI_ASSOC, false); } public function supportCount(int $newClassId, int $levelClassId): int { return (int)self::$db->scalar(" SELECT count(DISTINCT DisplayStaff) FROM permissions WHERE ID IN (?, ?) ", $newClassId, $levelClassId ); } public function updateCatchup(): bool { return (new WitnessTable\UserReadForum())->witness($this); } public function addClasses(array $classes): int { self::$db->prepared_query(" INSERT IGNORE INTO users_levels (UserID, PermissionID) VALUES " . implode(', ', array_fill(0, count($classes), "({$this->id}, ?)")), ...$classes ); $affected = self::$db->affected_rows(); $this->flush(); return $affected; } public function removeClasses(array $classes): int { self::$db->prepared_query(" DELETE FROM users_levels WHERE UserID = ? AND PermissionID IN (" . placeholders($classes) . ")", $this->id, ...$classes ); $affected = self::$db->affected_rows(); $this->flush(); return $affected; } public function notifyFilters(): array { $key = sprintf(self::CACHE_NOTIFY, $this->id); if ($this->forceCacheFlush || ($filters = self::$cache->get_value($key)) === false) { self::$db->prepared_query(' SELECT ID, Label FROM users_notify_filters WHERE UserID = ? ', $this->id ); $filters = self::$db->to_pair('ID', 'Label', false); self::$cache->cache_value($key, $filters, 2_592_000); } return $filters; } public function removeNotificationFilter(int $notifId): int { self::$db->prepared_query(' DELETE FROM users_notify_filters WHERE UserID = ? AND ID = ? ', $this->id, $notifId ); $removed = self::$db->affected_rows(); if ($removed) { self::$cache->delete_multi(['u_notify_' . $this->id, 'notify_artists_' . $this->id]); } return $removed; } public function loadArtistNotifications(): array { $info = self::$cache->get_value('notify_artists_' . $this->id); if (empty($info)) { self::$db->prepared_query(" SELECT ID, Artists FROM users_notify_filters WHERE Label = ? AND UserID = ? ORDER BY ID LIMIT 1 ", 'Artist notifications', $this->id ); $info = self::$db->next_record(MYSQLI_ASSOC, false); if (!$info) { $info = ['ID' => 0, 'Artists' => '']; } self::$cache->cache_value('notify_artists_' . $this->id, $info, 0); } return $info; } public function hasArtistNotification(string $name): bool { $info = $this->loadArtistNotifications(); return stripos($info['Artists'], "|$name|") !== false; } public function addArtistNotification(\Gazelle\Artist $artist): int { $info = $this->loadArtistNotifications(); $alias = implode('|', $artist->aliasNameList()); if (!$alias) { return 0; } $change = 0; if (!$info['ID']) { self::$db->prepared_query(" INSERT INTO users_notify_filters (UserID, Label, Artists) VALUES (?, ?, ?) ", $this->id, 'Artist notifications', "|$alias|" ); $change = self::$db->affected_rows(); } elseif (stripos($info['Artists'], "|$alias|") === false) { self::$db->prepared_query(" UPDATE users_notify_filters SET Artists = ? WHERE ID = ? AND Artists NOT LIKE concat('%', ?, '%') ", $info['Artists'] . "$alias|", $info['ID'], "|$alias|" ); $change = self::$db->affected_rows(); } if ($change) { self::$cache->delete_multi(['u_notify_' . $this->id, 'notify_artists_' . $this->id]); } return $change; } public function notifyDeleteSeeding(): bool { return !$this->hasAttr('no-pm-delete-seed'); } public function notifyDeleteSnatch(): bool { return !$this->hasAttr('no-pm-delete-snatch'); } public function notifyDeleteDownload(): bool { return !$this->hasAttr('no-pm-delete-download'); } public function removeArtistNotification(\Gazelle\Artist $artist): int { $info = $this->loadArtistNotifications(); $aliasList = $artist->aliasNameList(); foreach ($aliasList as $alias) { while (stripos($info['Artists'], "|$alias|") !== false) { $info['Artists'] = str_ireplace("|$alias|", '|', $info['Artists']); } } if ($info['Artists'] === '||') { self::$db->prepared_query(" DELETE FROM users_notify_filters WHERE ID = ? ", $info['ID'] ); $change = self::$db->affected_rows(); } else { self::$db->prepared_query(" UPDATE users_notify_filters SET Artists = ? WHERE ID = ? ", $info['Artists'], $info['ID'] ); $change = self::$db->affected_rows(); } if ($change) { self::$cache->delete_multi(['u_notify_' . $this->id, 'notify_artists_' . $this->id]); } return $change; } public function isUnconfirmed(): bool { return $this->info()['Enabled'] == UserStatus::unconfirmed->value; } public function isEnabled(): bool { return $this->info()['Enabled'] == UserStatus::enabled->value; } public function isDisabled(): bool { return $this->info()['Enabled'] == UserStatus::disabled->value; } public function isLocked(): bool { return !is_null($this->info()['locked_account']); } public function isVisible(): bool { return $this->info()['Visible'] == '1'; } public function isWarned(): bool { return !is_null($this->warningExpiry()); } public function isStaff(): bool { return $this->info()['isStaff']; } public function isFLS(): bool { return $this->privilege()->isFLS(); } public function isInterviewer(): bool { return $this->privilege()->isInterviewer(); } public function isRecruiter(): bool { return $this->privilege()->isRecruiter(); } public function isStaffPMReader(): bool { return $this->isFLS() || $this->isStaff(); } public function warningExpiry(): ?string { return $this->info()['warning_expiry']; } /** * How many personal collages is this user allowed to create? * * @return int number of collages (including collages granted from donations) */ public function allowedPersonalCollages(): int { return $this->paidPersonalCollages() + (new User\Donor($this))->collageTotal(); } /** * How many collages has this user bought? * * @return int number of collages */ public function paidPersonalCollages(): int { return $this->info()['collage_total']; } /** * How many personal collages has this user created? * * @return int number of active collages */ public function activePersonalCollages(): int { return (int)self::$db->scalar(" SELECT count(*) FROM collages WHERE CategoryID = 0 AND Deleted = '0' AND UserID = ? ", $this->id ); } /** * Is this user allowed to create a new personal collage? * * @return bool Yes we can */ public function canCreatePersonalCollage(): bool { return $this->allowedPersonalCollages() > $this->activePersonalCollages(); } public function collageUnreadCount(): int { if (($new = self::$cache->get_value(sprintf(Collage::SUBS_NEW_KEY, $this->id))) === false) { $new = self::$db->scalar(" SELECT count(*) FROM ( SELECT s.LastVisit FROM users_collage_subs s INNER JOIN collages c ON (c.ID = s.CollageID) LEFT JOIN collages_torrents ct ON (ct.CollageID = s.CollageID) LEFT JOIN collages_artists ca ON (ca.CollageID = s.CollageID) WHERE c.Deleted = '0' AND s.UserID = ? GROUP BY s.CollageID HAVING max(coalesce(ct.AddedOn, ca.AddedOn)) > s.LastVisit ) unread ", $this->id ); self::$cache->cache_value(sprintf(Collage::SUBS_NEW_KEY, $this->id), $new, 0); } return $new; } public function clients(): array { self::$db->prepared_query(' SELECT DISTINCT useragent FROM xbt_files_users WHERE uid = ? ', $this->id ); return self::$db->collect(0) ?: ['None']; } protected function getSingleValue($cacheKey, $query): string { $cacheKey .= '_' . $this->id; if ($this->forceCacheFlush || ($value = self::$cache->get_value($cacheKey)) === false) { $value = (string)self::$db->scalar($query, $this->id); self::$cache->cache_value($cacheKey, $value, 3600); } return $value; } public function passwordCount(): int { return (int)$this->getSingleValue('user_pw_count', ' SELECT count(*) FROM users_history_passwords WHERE UserID = ? '); } public function announceKeyCount(): int { return (int)$this->getSingleValue('user_passkey_count', ' SELECT count(*) FROM users_history_passkeys WHERE UserID = ? '); } public function trackerIPCount(): int { return (int)$this->getSingleValue('user_trackip_count', " SELECT count(DISTINCT IP) FROM xbt_snatched WHERE uid = ? AND IP != '' "); } public function inviter(): ?User { return $this->inviterId() ? new User($this->inviterId()) : null; } public function inviterId(): int { return (int)$this->info()['inviter_user_id']; } /** * This can be used to add a signature to a URL to prevent tampering with the parameter list. */ public function hashHmac(string $topic, string $message): string { return urlencode_safe( hash_hmac('sha3-256', $message, USER_HASH_SALT . "$topic|{$this->auth()}", true) ); } public function unusedInviteTotal(): int { return $this->disableInvites() ? 0 : $this->info()['Invites']; } public function passwordAge(): int { return time() - (int)$this->getSingleValue('user_pw_epoch', " SELECT unix_timestamp(coalesce(max(uhp.ChangeTime), um.created)) FROM users_main um LEFT JOIN users_history_passwords uhp ON (uhp.UserID = um.ID) WHERE um.ID = ? "); } public function forumWarning(): ?string { $warning = $this->getSingleValue('user_forum_warn', " SELECT Comment FROM users_warnings_forums WHERE UserID = ? "); return $warning ?: null; } public function collagesCreated(): int { return (int)$this->getSingleValue('user_collage_create', " SELECT count(*) FROM collages WHERE Deleted = '0' AND UserID = ? "); } public function tagSnatchCounts(int $limit = 8): array { $list = self::$cache->get_value('user_tag_snatch_' . $this->id); if ($list === false) { self::$db->prepared_query(" SELECT tg.Name AS name, tg.ID AS id, count(*) AS n FROM torrents_tags tt INNER JOIN tags tg ON (tg.ID = tt.TagID) INNER JOIN ( SELECT DISTINCT t.GroupID FROM xbt_snatched xs INNER JOIN torrents t ON (t.id = xs.fid) WHERE tstamp > unix_timestamp(now() - INTERVAL 6 MONTH) AND xs.uid = ? ) SN USING (GroupID) GROUP BY tg.ID ORDER BY 3 DESC, 1 LIMIT ? ", $this->id, $limit ); $list = self::$db->to_array(false, MYSQLI_ASSOC, false); self::$cache->cache_value('user_tag_snatch_' . $this->id, $list, 86400 * 90); } return $list; } /** * Default list 5 will be cached. When fetching a different amount, * set $forceNoCache to true to avoid caching a list with an unexpected length */ public function recentUploadList(int $limit = 5, bool $forceNoCache = false): array { $key = sprintf(self::USER_RECENT_UPLOAD, $this->id); $recent = self::$cache->get_value($key); if ($forceNoCache) { $recent = false; } if ($recent === false) { self::$db->prepared_query(" SELECT g.ID FROM torrents_group AS g INNER JOIN torrents AS t ON (t.GroupID = g.ID) WHERE g.WikiImage != '' AND g.CategoryID = '1' AND t.UserID = ? GROUP BY g.ID ORDER BY t.created DESC LIMIT ? ", $this->id, $limit ); $recent = self::$db->collect(0, false); if (!$forceNoCache) { self::$cache->cache_value($key, $recent, 86400 * 3); } } return $recent; } public function torrentDownloadCount(int $torrentId): int { return (int)self::$db->scalar(' SELECT count(*) FROM users_downloads ud INNER JOIN torrents AS t ON (t.ID = ud.TorrentID) WHERE ud.UserID = ? AND ud.TorrentID = ? ', $this->id, $torrentId ); } public function torrentRecentRemoveCount(int $hours): int { return (int)self::$db->scalar(' SELECT count(*) FROM user_torrent_remove utr WHERE utr.user_id = ? AND utr.removed >= now() - INTERVAL ? HOUR ', $this->id, $hours ); } /** * Generates a check list of release types, ordered by the user or default */ public function releaseOrder(array $releaseType): array { if (empty($this->option('SortHide'))) { $sort = $releaseType; $defaults = !empty($this->option('HideTypes')); } else { $sort = (array)$this->option('SortHide'); $missingTypes = array_diff_key($releaseType, $sort); foreach (array_keys($missingTypes) as $missing) { $sort[$missing] = 0; } } $order = []; foreach ($sort as $key => $val) { if (isset($defaults)) { $checked = $defaults && isset($this->option('HideTypes')[$key]); } elseif (isset($releaseType[$key])) { $checked = $val; $val = $releaseType[$key]; } else { $checked = true; } $order[] = ['id' => $key . '_' . (int)(!!$checked), 'checked' => $checked, 'label' => $val]; } return $order; } public function tokenCount(): int { return $this->info()['FLTokens']; } /** * Check if the viewer has an active freeleech token on this torrent */ public function hasToken(TorrentAbstract $torrent): bool { if (!isset($this->tokenCache)) { $key = "users_tokens_" . $this->id; $tokenCache = self::$cache->get_value($key); if ($tokenCache === false) { $qid = self::$db->get_query_id(); self::$db->prepared_query(" SELECT TorrentID FROM users_freeleeches WHERE Expired = 0 AND UserID = ? ", $this->id ); $tokenCache = array_fill_keys(self::$db->collect(0, false), true); self::$db->set_query_id($qid); self::$cache->cache_value($key, $tokenCache, 3600); } $this->tokenCache = $tokenCache; } return isset($this->tokenCache[$torrent->id()]); } /** * Can the user spend a token (or more) to set this torrent Freeleech? * Note: The torrent object MUST be instantiated with setViewer() set * to the user. */ public function canSpendFLToken(Torrent $torrent): bool { return $this->canLeech() && !$torrent->isFreeleech() && !$torrent->isFreeleechPersonal() && (STACKABLE_FREELEECH_TOKENS || $torrent->tokenCount() == 1) && $this->tokenCount() >= $torrent->tokenCount(); } /** * Get a page of FL token uses by user * * @param int $limit How many? (To fill a page) * @param int $offset From where (which page) * @return array [torrent_id, group_id, created, expired, downloaded, uses, group_name, format, encoding, size] */ public function tokenList(Manager\Torrent $torMan, int $limit, int $offset): array { self::$db->prepared_query(" SELECT t.GroupID AS group_id, g.Name AS group_name, f.TorrentId AS torrent_id, t.Size AS size, f.Time AS created, f.Expired AS expired, f.Downloaded AS downloaded, f.Uses AS uses FROM users_freeleeches AS f LEFT JOIN torrents AS t ON (t.ID = f.TorrentID) LEFT JOIN torrents_group AS g ON (g.ID = t.GroupID) WHERE f.UserID = ? ORDER BY f.Time DESC LIMIT ? OFFSET ? ", $this->id, $limit, $offset ); $list = []; $torrents = self::$db->to_array(false, MYSQLI_ASSOC, false); foreach ($torrents as $t) { $torrent = $torMan->findById($t['torrent_id']); $t['name'] = $torrent ? $torrent->fullLink() : "(Deleted torrent {$t['torrent_id']})"; $list[] = $t; } return $list; } /** * Scale down user rank if certain steps have not been taken * @return float Scaling factor between 0.0 and 1.0 */ public function rankFactor(): float { $factor = 1.0; if (is_null($this->avatar())) { $factor *= 0.75; } if (!strlen($this->profileInfo())) { $factor *= 0.75; } return $factor; } /** * Add request bounty and update stats immediately. * Negative bounty can be added (!) in the case of a request unfill. */ public function addBounty(int $bounty): int { if ($bounty > 0) { // adding self::$db->prepared_query(" UPDATE users_leech_stats SET Uploaded = Uploaded + ? WHERE UserID = ? ", $bounty, $this->id ); } else { // removing, $bounty is negative $this->flush(); $uploaded = $this->uploadedSize(); if ($uploaded + $bounty < 0) { // If we can't take it all out of upload, zero that out and add whatever is left as download. self::$db->prepared_query(" UPDATE users_leech_stats SET Uploaded = 0, Downloaded = Downloaded + ? WHERE UserID = ? ", $uploaded + $bounty, $this->id ); } else { self::$db->prepared_query(" UPDATE users_leech_stats SET Uploaded = Uploaded + ? WHERE UserID = ? ", $bounty, $this->id ); } } $nr = self::$db->affected_rows(); $this->flush(); $this->stats()->increment('request_bounty_total', $bounty > 0 ? 1 : -1); $this->stats()->increment('request_bounty_size', $bounty); return $nr; } public function buffer(): array { $class = $this->primaryClass(); $demotion = array_filter((new Manager\User())->demotionCriteria(), fn($v) => in_array($class, $v['From'])); $criteria = end($demotion); $effectiveUpload = $this->uploadedSize() + $this->stats()->requestBountySize(); if ($criteria) { $ratio = $criteria['Ratio']; } else { $ratio = $this->requiredRatio(); } return [$ratio, $ratio == 0 ? $effectiveUpload : $effectiveUpload / $ratio - $this->downloadedSize()]; } public function nextClass(Manager\User $manager): ?array { $criteria = $manager->promotionCriteria()[$this->primaryClass()] ?? null; if (!$criteria) { return null; } $upload = $this->uploadedSize(); $download = $this->downloadedSize(); $bounty = $this->stats()->requestVoteSize(); $week = $criteria['Weeks']; $goal = [ 'Upload' => [ 'current' => byte_format($upload + $bounty), 'target' => byte_format($criteria['MinUpload']), 'percent' => ratio_percent(($upload + $bounty) / $criteria['MinUpload']), ], 'Ratio' => [ 'current' => $download == 0 ? '∞' : number_format($upload / $download, 2), 'target' => number_format($criteria['MinRatio'], 2), 'percent' => ratio_percent($download == 0 ? 1 : ($upload / $download) / $criteria['MinRatio']), ], 'Time' => [ 'current' => $this->created(), 'target' => "$week week" . plural($week), 'percent' => ratio_percent((time() - strtotime($this->created())) / ($criteria['Weeks'] * 7 * 86_400)), ], ]; if ($criteria['MinUploads']) { $uploadTotal = $this->stats()->uploadTotal(); $goal['Torrents'] = [ 'current' => number_format($uploadTotal), 'target' => $criteria['MinUploads'], 'percent' => ratio_percent($uploadTotal / $criteria['MinUploads']), ]; } if (isset($criteria['Extra'])) { foreach ($criteria['Extra'] as $req => $info) { $query = (string)$info['Query']; $query = (str_starts_with($query, 'us.')) ? "SELECT $query FROM user_summary us WHERE user_id = ?" : str_replace('um.ID', '?', $query); $current = (int)self::$db->scalar($query, ...array_fill(0, substr_count($query, '?'), $this->id)); if ($req == SITE_NAME . ' Upload') { $goal[$req] = [ 'current' => byte_format($current), 'target' => byte_format($info['Count']), 'percent' => ratio_percent($current / $info['Count']), ]; } else { $goal[$req] = [ 'current' => number_format($current), 'target' => $info['Count'], 'percent' => ratio_percent($current / $info['Count']), ]; } } } return [ 'class' => $manager->userclassName($criteria['To']), 'goal' => $goal, ]; } /** * See whether a user is seeding a torrent. This method has no caching, but is * only expected to be called at the moment a user wants to download a torrent. */ public function isSeeding(TorrentAbstract $torrent): bool { return (bool)self::$db->scalar(" SELECT 1 FROM xbt_files_users WHERE uid = ? AND fid = ? LIMIT 1; ", $this->id, $torrent->id() ); } public function seedingSize(): int { return (int)$this->getSingleValue('seeding_size', ' SELECT coalesce(sum(t.Size), 0) FROM ( SELECT DISTINCT fid FROM xbt_files_users WHERE active = 1 AND remaining = 0 AND mtime > unix_timestamp(now() - INTERVAL 1 HOUR) AND uid = ? ) AS xfu INNER JOIN torrents AS t ON (t.ID = xfu.fid) '); } public function createApiToken(string $name): string { while (true) { // prevent collisions with an existing token $token = base64_encode(random_bytes(87)); try { self::$db->prepared_query(" INSERT INTO api_tokens (user_id, name, token) VALUES (?, ?, ?) ", $this->id, $name, $token ); return $token; } catch (\Gazelle\DB\MysqlDuplicateKeyException) { ; } } } public function apiTokenList(bool $revoked = false): array { self::$db->prepared_query(" SELECT id, name, token, created FROM api_tokens WHERE user_id = ? AND revoked = ? ORDER BY created DESC ", $this->id, (int)$revoked ); return self::$db->to_array(false, MYSQLI_ASSOC, false); } public function hasApiTokenByName(string $name): bool { return (bool)self::$db->scalar(" SELECT 1 FROM api_tokens WHERE revoked = 0 AND user_id = ? AND name = ? ", $this->id, $name ); } public function hasApiToken(string $token): bool { return (bool)self::$db->scalar(" SELECT 1 FROM api_tokens WHERE revoked = 0 AND user_id = ? AND token = ? ", $this->id, $token ); } public function revokeApiTokenById(int $tokenId): int { self::$db->prepared_query(" UPDATE api_tokens SET revoked = 1 WHERE user_id = ? AND id = ? ", $this->id, $tokenId ); return self::$db->affected_rows(); } /** * Checks whether a user is allowed to issue an invite. * - invites not disabled * - not on ratio watch * - leeching privs not suspended * * @return boolean false if they have been naughty, otherwise true */ public function canInvite(): bool { return $this->permitted('site_send_unlimited_invites') || ( !$this->onRatioWatch() && $this->canLeech() && $this->canPurchaseInvite() ); } /** * Checks whether a user is allowed to purchase an invite. Lower classes are capped, * users above this class will always return true. * * @return boolean false if insufficient funds, otherwise true */ public function canPurchaseInvite(): bool { return !$this->disableInvites() && $this->privilege()->effectiveClassLevel() >= MIN_INVITE_CLASS; } }