show invite source in invite pool

This commit is contained in:
Spine
2025-03-15 01:40:53 +00:00
parent 94b7c8add1
commit ec2b8d8f64
11 changed files with 244 additions and 114 deletions

View File

@@ -15,7 +15,7 @@ class Invite extends \Gazelle\Base {
INSERT INTO invites
(InviterID, InviteKey, Email, Notes, Reason, Expires)
VALUES (?, ?, ?, ?, ?, now() + INTERVAL 3 DAY)
", $user->id(), $inviteKey, $email, $notes, $reason
", $user->id, $inviteKey, $email, $notes, $reason
);
$invite = new \Gazelle\Invite($inviteKey);
if (is_number($source)) {
@@ -25,7 +25,7 @@ class Invite extends \Gazelle\Base {
return $invite;
}
public function findUserByKey(string $inviteKey, User $manager): ?\Gazelle\User {
public function findUserByKey(string $inviteKey, User $manager = new User()): ?\Gazelle\User {
return $manager->findById(
(int)self::$db->scalar("
SELECT InviterID FROM invites WHERE InviteKey = ?
@@ -59,7 +59,7 @@ class Invite extends \Gazelle\Base {
FROM invites
WHERE InviterID = ?
AND Email = ?
", $user->id(), $email
", $user->id, $email
);
}
@@ -86,12 +86,15 @@ class Invite extends \Gazelle\Base {
self::$db->prepared_query("
SELECT i.InviterID AS user_id,
um.IP AS ipaddr,
i.InviteKey AS `key`,
i.Expires AS expires,
i.Email AS email
FROM invites AS i
um.IP AS ipaddr,
i.InviteKey AS `key`,
i.Expires AS expires,
i.Email AS email,
ivs.name AS source_name
FROM invites i
INNER JOIN users_main AS um ON (um.ID = i.InviterID)
LEFT JOIN invite_source_pending ivsp ON (ivsp.invite_key = i.InviteKey)
LEFT JOIN invite_source ivs USING (invite_source_id)
$where
ORDER BY i.Expires DESC
LIMIT ? OFFSET ?
@@ -101,7 +104,7 @@ class Invite extends \Gazelle\Base {
}
/**
* Remove an invite
* Remove an invite without restoring it to the issuer
*
* @return bool true if something was actually removed
*/
@@ -115,25 +118,24 @@ class Invite extends \Gazelle\Base {
}
/**
* Expire unused invitations
* Expire unused invitations and return them to the user
*/
public function expire(\Gazelle\Task|null $task = null): int {
self::$db->begin_transaction();
self::$db->prepared_query("SELECT InviterID FROM invites WHERE Expires < now()");
$list = self::$db->collect(0, false);
self::$db->prepared_query("DELETE FROM invites WHERE Expires < now()");
self::$db->prepared_query("
DELETE isp FROM invite_source_pending isp
LEFT JOIN invites i ON (i.InviteKey = isp.invite_key)
WHERE i.InviteKey IS NULL
");
public function expire(\Gazelle\Task|null $task = null, User $manager = new User()): int {
$expired = 0;
foreach ($list as $userId) {
self::$db->prepared_query("UPDATE users_main SET Invites = Invites + 1 WHERE ID = ?", $userId);
self::$cache->delete_value("u_$userId");
$task?->debug("Expired invite from user $userId", $userId);
self::$db->begin_transaction();
self::$db->prepared_query("
SELECT InviterID AS 'user_id',
InviteKey AS 'invite_key'
FROM invites
WHERE Expires < now()
");
foreach (self::$db->to_array(false, MYSQLI_ASSOC, false) as $row) {
$user = $manager->findById($row['user_id']);
if (is_null($user)) {
continue;
}
$user->invite()->revoke($row['invite_key']);
$task?->debug("Expired invite {$row['invite_key']} for user {$user->username()}", $row['user_id']);
$expired++;
}
self::$db->commit();

View File

@@ -9,7 +9,7 @@ class Registration extends \Gazelle\Base {
protected string $afterDate;
public function __construct(
protected User $manager,
protected User $manager = new User(),
) {}
public function setBeforeDate(string $date): static {

View File

@@ -12,7 +12,7 @@ use Gazelle\Util\Time;
class User extends BaseObject {
final public const tableName = 'users_main';
final protected const CACHE_KEY = 'u2_%d';
final protected const CACHE_KEY = 'u_%d';
final protected const CACHE_NOTIFY = 'u_notify_%d';
final protected const CACHE_REFERRAL = 'u_refer_%d';
final protected const USER_RECENT_UPLOAD = 'u_recent_up_%d';
@@ -46,7 +46,7 @@ class User extends BaseObject {
$this->stats()->flush();
$this->ordinal()->flush();
$this->privilege()->flush();
unset($this->info, $this->ordinal, $this->privilege, $this->stats, $this->tokenCache);
unset($this->info, $this->invite, $this->ordinal, $this->privilege, $this->stats, $this->tokenCache);
return $this;
}
@@ -157,6 +157,7 @@ class User extends BaseObject {
um.torrent_pass,
um.updated,
um.Visible,
um.ipcc,
ui.AdminComment,
ui.BanDate,
ui.NavItems,
@@ -472,6 +473,10 @@ class User extends BaseObject {
return $this->info()['IP'];
}
public function ipCountryIso(): string {
return $this->info()['ipcc'];
}
public function IRCKey(): ?string {
return $this->info()['IRCKey'];
}
@@ -1583,6 +1588,15 @@ class User extends BaseObject {
return (int)$this->info()['inviter_user_id'];
}
public function inviteSource(): ?string {
return $this->getSingleValue('user_invitesource', "
SELECT ivs.name
FROM user_has_invite_source uhivs
LEFT JOIN invite_source ivs using (invite_source_id)
WHERE uhivs.user_id = ?
");
}
/**
* This can be used to add a signature to a URL to prevent tampering with the parameter list.
*/

View File

@@ -27,35 +27,6 @@ class Invite extends \Gazelle\BaseUser {
return $affected > 0;
}
/**
* Revoke an active invitation (restore previous invite total)
*/
public function revoke(string $key): bool {
self::$db->begin_transaction();
self::$db->prepared_query("
DELETE FROM invites WHERE InviteKey = ?
", $key
);
if (self::$db->affected_rows() == 0) {
self::$db->rollback();
return false;
}
if ($this->user()->permitted('site_send_unlimited_invites')) {
self::$db->commit();
return true;
}
self::$db->prepared_query("
UPDATE users_main SET
Invites = Invites + 1
WHERE ID = ?
", $this->id()
);
self::$db->commit();
$this->user()->flush();
return true;
}
public function pendingTotal(): int {
return (int)self::$db->scalar("
SELECT count(*) FROM invites WHERE InviterID = ?
@@ -65,12 +36,15 @@ class Invite extends \Gazelle\BaseUser {
public function pendingList(): array {
self::$db->prepared_query("
SELECT InviteKey AS invite_key,
Email AS email,
Expires AS expires
FROM invites
WHERE InviterID = ?
ORDER BY Expires
SELECT i.InviteKey AS invite_key,
i.Email AS email,
i.Expires AS expires,
ivs.name AS source_name
FROM invites i
LEFT JOIN invite_source_pending ivsp ON (ivsp.invite_key = i.InviteKey)
LEFT JOIN invite_source ivs USING (invite_source_id)
WHERE i.InviterID = ?
ORDER BY i.Expires
", $this->id()
);
return self::$db->to_array('invite_key', MYSQLI_ASSOC, false);
@@ -96,4 +70,39 @@ class Invite extends \Gazelle\BaseUser {
);
return self::$db->collect(0, false);
}
/**
* Revoke an active invitation (restore previous invite total)
*/
public function revoke(string $key): int {
self::$db->begin_transaction();
self::$db->prepared_query("
DELETE FROM invites WHERE InviteKey = ?
", $key
);
$affected = self::$db->affected_rows();
if ($affected == 0) {
self::$db->rollback();
return 0;
}
if ($this->user()->permitted('site_send_unlimited_invites')) {
self::$db->commit();
return $affected;
}
self::$db->prepared_query("
DELETE FROM invite_source_pending WHERE invite_key = ?
", $key
);
$affected += self::$db->affected_rows();
self::$db->prepared_query("
UPDATE users_main SET
Invites = Invites + 1
WHERE ID = ?
", $this->id()
);
$affected += self::$db->affected_rows();
self::$db->commit();
$this->user()->flush();
return $affected;
}
}

View File

@@ -10,7 +10,7 @@ if (!$Viewer->permittedAny('users_view_ips', 'users_view_email')) {
error(403);
}
$registration = new Manager\Registration(new Manager\User());
$registration = new Manager\Registration();
if (isset($_REQUEST['before_date'])) {
if (!str_contains($_SERVER['REQUEST_URI'], '&before_date=')) {

View File

@@ -35,7 +35,7 @@ if ($Viewer->isInterviewer() || $Viewer->isStaff()) {
}
$inviteSourceMan = null;
if ($Viewer->isRecruiter()) {
if ($Viewer->isRecruiter() || $Viewer->isStaff()) {
$inviteSourceMan = new Manager\InviteSource();
}

View File

@@ -1,5 +1,23 @@
{{ header('Registration log', {'js': 'resolve-ip'}) }}
<div class="thin">
<div class="header">
{% if not list %}
<h2 align="center">No new User Registrations
{%- else -%}
<h2 align="center">{{ paginator.total|number_format }} new User Registration{{ paginator.total|plural }}
{%- endif %}
{%- if after -%}
{%- if before %} between {{ after }} and {{ before }}
{%- else %} after {{ after -}}
{%- endif -%}
{%- elseif before %} before {{ before -}}
{%- else %} in the last 72 hours
{%- endif -%}
</h2>
</div>
<div class="linkbox">
<a href="?action=invite_pool" class="brackets">Invite Pool</a>
</div>
<div class="box pad">
<form action="" method="post" acclass="thin box pad">
<input type="hidden" name="action" value="registration_log" />
@@ -12,22 +30,9 @@
</div>
</div>
{% if not list %}
<h2 align="center">No new user registrations
{%- else -%}
<h2 align="center">{{ paginator.total|number_format }} New user registration{{ paginator.total|plural }}
{%- endif %}
{%- if after -%}
{%- if before %} between {{ after }} and {{ before }}
{%- else %} after {{ after -}}
{%- endif -%}
{%- elseif before %} before {{ before -}}
{%- else %} in the last 72 hours
{%- endif -%}
</h2>
{% if list %}
{{ paginator.linkbox|raw }}
{% for user in list %}
{% if loop.first %}
{{ paginator.linkbox|raw }}
<table width="100%">
<tr class="colhead">
<td>User</td>
@@ -39,8 +44,9 @@
<td colspan="2">Host</td>
<td>Country</td>
<td>Registered</td>
<td>Source</td>
</tr>
{% for user in list %}
{% endif %}
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
<td>
{{ user.id|user_full }}
@@ -49,24 +55,24 @@
</td>
<td style="vertical-align: top">
{{- user.uploadedSize|octet_size -}}
{%- if user.inviter -%}
{% if user.inviter -%}
<br />
{{- user.inviter.uploadedSize|octet_size -}}
{%- endif -%}
{% endif -%}
</td>
<td style="vertical-align: top">
{{- user.downloadedSize|octet_size -}}
{%- if user.inviter -%}
{% if user.inviter -%}
<br />
{{- user.inviter.downloadedSize|octet_size -}}
{%- endif -%}
{% endif -%}
</td>
<td style="vertical-align: top">
{{- ratio(user.uploadedSize, user.downloadedSize) -}}
{%- if user.inviter -%}
{% if user.inviter -%}
<br />
{{- ratio(user.inviter.uploadedSize, user.inviter.downloadedSize) -}}
{%- endif -%}
{% endif -%}
</td>
<td style="vertical-align: top">
{{- user.email -}}
@@ -76,62 +82,66 @@
<td style="vertical-align: top">
<a href="userhistory.php?action=email&amp;userid={{ user.id }}" title="Email History" class="brackets tooltip">H</a>
<a href="/user.php?action=search&amp;email_history=on&amp;email={{ user.email }}" title="Email Search" class="brackets tooltip">S</a>
{%- if user.inviter -%}
{% if user.inviter -%}
<br />
<a href="userhistory.php?action=email&amp;userid={{ user.inviter.id }}" title="Email History" class="brackets tooltip">H</a>
<a href="/user.php?action=search&amp;email_history=on&amp;email={{ user.inviter.email }}" title="Email Search" class="brackets tooltip">S</a>
{%- endif -%}
{% endif -%}
</td>
<td style="vertical-align: top">
<span style="float: left">
{{- user.ipaddr -}}
{%- if user.inviter.id -%}
{% if user.inviter.id -%}
<br />
{{- user.inviter.ipaddr -}}
{%- endif -%}
{% endif -%}
</span>
{%- if user.inviter and user.ipaddr == user.inviter.ipaddr -%}
{% if user.inviter and user.ipaddr == user.inviter.ipaddr -%}
<span title="IP addresses match" style="float: left; padding: 0px 5px; color: #ffff00; font-size: large">&#x26A0;</span>
{%- endif -%}
{% endif -%}
</td>
<td style="vertical-align: top">
<span style="float: left; padding-left: 2px;" title="Duplicate usage by other users">
{{- ipv4.duplicateTotal(user) -}}
{%- if user.inviter and user.ipaddr != user.inviter.ipaddr -%}
{% if user.inviter and user.ipaddr != user.inviter.ipaddr -%}
<br />
{{- ipv4.duplicateTotal(user.inviter) -}}
{% endif %}
{% endif %}
</span>
</td>
<td style="vertical-align: top">
<span class="resolve-ipv4" data-ip="{{ user.ipaddr }}">Resolving...</span>
{% if user.inviter.id and user.inviter.ipaddr != user.ipaddr %}
{% if user.inviter.id and user.inviter.ipaddr != user.ipaddr %}
<br />
<span class="resolve-ipv4" data-ip="{{ user.inviter.ipaddr }}">Resolving...</span>
{% endif %}
{% endif %}
</td>
<td style="vertical-align: top">
<a href="userhistory.php?action=ips&amp;userid{{ user.id }}" title="IP History" class="brackets tooltip">H</a>
<a href="/user.php?action=search&amp;ip_history=on&amp;ip={{ user.ipaddr }}" title="IP Search" class="brackets tooltip">S</a>
<a href="http://whatismyipaddress.com/ip/{{ user.ipaddr }}" title="whatismyipaddress.com" class="brackets tooltip">WI</a>
{% if user.inviter.id and user.inviter.ipaddr != user.ipaddr %}
{% if user.inviter.id and user.inviter.ipaddr != user.ipaddr %}
<br />
<a href="userhistory.php?action=ips&amp;userid={{ user.inviter.id }}" title="IP History" class="brackets tooltip">H</a>
<a href="/user.php?action=search&amp;ip_history=on&amp;ip={{ user.inviter.ipaddr }}" title="IP Search" class="brackets tooltip">S</a>
<a href="http://whatismyipaddress.com/ip/{{ user.inviter.ipaddr }}" title="WI" class="brackets tooltip">WI</a>
{% endif %}
{% endif %}
</td>
<td>
TODO
{{ user.ipCountryIso }}
</td>
<td>
<span style="white-space: nowrap">{{- user.created|time_diff -}}</span>
<br />
<span style="white-space: nowrap">{{- user.inviter.created|time_diff -}}</span>
</td>
<td>
{{ user.inviteSource }}
</td>
</tr>
{% endfor %}
{% if loop.last %}
</table>
{{ paginator.linkbox|raw }}
{% endif %}
{% endif %}
{% endfor %}
{{ footer() }}

View File

@@ -2,7 +2,9 @@
<div class="header">
<h2>Invite Pool</h2>
</div>
<div class="linkbox">
<a href="?action=registration_log" class="brackets">Registration Log</a>
</div>
<div class="box pad">
<p>{{ pending|number_format }} unused invites have been sent.</p>
{% if removed is not empty %}
@@ -34,6 +36,7 @@
<tr class="colhead">
<td>Inviter</td>
<td>Email address</td>
<td>Source</td>
<td>IP address</td>
<td>Invite link</td>
<td>Expires</td>
@@ -46,8 +49,9 @@
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
<td>{{ invite.user_id|user_full }}</td>
<td>{{ invite.email }}</td>
<td>{{ invite.source_name }}</td>
<td>{{ ipaddr(invite.ipaddr) }}</td>
<td><a href="register.php?invite={{ invite.key }}">{{ invite.key }}</a></td>
<td>{{ constant('SITE_URL') }}/register.php?invite={{ invite.key }}</td>
<td>{{ invite.expires|time_diff }}</td>
{% if viewer.permitted('users_edit_invites') %}
<td>

View File

@@ -122,20 +122,26 @@
{% endif %}
{% for p in user.invite.pendingList %}
{% if loop.first %}
{% if loop.first %}
<h3>Pending invites</h3>
<div class="box pad">
<table width="100%">
<tr class="colhead">
<td>Email address</td>
<td>Expires in</td>
{% if is_site_inviter %}
<td>Source</td>
{% endif %}
<td>Invite link</td>
<td>Revoke invite</td>
</tr>
{% endif %}
{% endif %}
<tr class="row{{ cycle(['a', 'b'], loop.index0) }}">
<td>{{ p.email }}</td>
<td>{{ p.expires|time_diff }}</td>
{% if is_site_inviter %}
<td>{{ p.source_name }}</td>
{% endif %}
<td>{{ constant('SITE_URL') }}/register.php?invite={{ p.invite_key }}</td>
<td><a href="user.php?action=delete_invite&amp;invite={{ p.invite_key }}&amp;auth={{ user.auth }}"
onclick="return confirm('Are you sure you want to revoke this invite?');">Revoke invite</a></td>

View File

@@ -330,7 +330,7 @@ class InviteTest extends TestCase {
'invite-source-create-pending'
);
// create auser from the invite
// create user from the invite
$this->invitee = (new UserCreator())
->setUsername('create.' . randomString(6))
->setEmail(randomString(6) . '@example.com')
@@ -342,6 +342,7 @@ class InviteTest extends TestCase {
$inviteSourceMan->findSourceNameByUser($this->invitee),
'invitee-invite-source'
);
$this->assertEquals($sourceName, $this->invitee->inviteSource(), 'invitee-source-name');
$this->assertEquals($profile, $this->invitee->externalProfile()->profile(), 'invite-source-profile');
$this->assertStringContainsString(
'phpunit notes',
@@ -421,16 +422,99 @@ class InviteTest extends TestCase {
$this->assertEquals(1, $inviteSourceMan->remove($sourceId), 'invite-source-create-remove');
}
public function testRemoveInvite(): void {
$this->user->setField('Invites', 1)->modify();
$manager = new Manager\Invite();
$invite = $manager->create(
$this->user,
'invite-unittest@unitte.st',
'unittest invite remove notes',
'unittest invite remove reason',
''
);
$this->assertTrue($manager->removeInviteKey($invite->key()), 'invite-remove-key');
$this->assertNull($manager->findUserByKey($invite->key()), 'invite-removed-key');
}
public function testExpireInvite(): void {
$this->user->setField('Invites', 1)->modify();
$this->assertEquals(1, $this->user->unusedInviteTotal(), 'invite-user-has-invite');
$manager = new Manager\Invite();
$invite = $manager->create(
$this->user,
'invite-unittest@unitte.st',
'unittest invite remove notes',
'unittest invite remove reason',
''
);
$this->assertEquals(0, $this->user->unusedInviteTotal(), 'invite-user-all-used');
// invites are not BaseObjects
DB::DB()->prepared_query("
UPDATE invites SET
Expires = now() - INTERVAL 1 SECOND
WHERE InviteKey = ?
", $invite->key()
);
$this->assertEquals(1, DB::DB()->affected_rows(), 'invite-modify-expiry');
$this->assertEquals(1, $manager->expire(), 'invite-manager-expire');
$this->assertEquals(1, $this->user->flush()->unusedInviteTotal(), 'invite-user-invite-restored');
}
public function testRevokeInvite(): void {
$this->user->setField('Invites', 1)->modify();
$manager = new Manager\Invite();
$initial = $manager->totalPending();
$email = randomString(10) . "@invitee.example.com";
$this->assertFalse($manager->emailExists($this->user, $email), 'invitee-email-not-pending');
$invite = $manager->create($this->user, $email, 'unittest notes', 'unittest reason', '');
$this->assertFalse($this->user->invite()->revoke('nosuchthing'), 'invite-revoke-inexistant');
$this->assertTrue($this->user->invite()->revoke($invite->key()), 'invite-revoke-existing');
$this->assertEquals(1, $this->user->unusedInviteTotal(), 'invite-unused-1');
$this->assertFalse(
$manager->emailExists($this->user, $email),
'invitee-email-not-pending'
);
$invite = $manager->create(
$this->user,
$email,
'unittest notes',
'unittest reason',
''
);
$this->assertEquals(
$initial + 1,
$manager->totalPending(),
'invite-total-pending-invites',
);
$this->assertEquals(
$this->user->id,
$manager->findUserByKey($invite->key(), new Manager\User())->id,
'invite-manager-find-by-key',
);
$pending = $manager->pendingInvites(1, 0);
$this->assertGreaterThan(0, count($pending), 'invite-list-pending');
$this->assertEquals(
['user_id', 'ipaddr', 'key', 'expires', 'email', 'source_name'],
array_keys($pending[0]),
'invite-pending-array-keys',
);
$this->assertEquals($this->user->id, $pending[0]['user_id'], 'invite-pending-id');
$this->assertEquals($invite->key(), $pending[0]['key'], 'invite-pending-id');
$this->assertTrue(
$manager->emailExists($this->user, $email),
'invite-email-exists',
);
$this->assertEquals(
0,
$this->user->invite()->revoke('nosuchthing'),
'invite-revoke-inexistant',
);
$this->assertEquals(
2,
$this->user->invite()->revoke($invite->key()),
'invite-revoke-existing',
);
$this->assertEquals(
1,
$this->user->unusedInviteTotal(),
'invite-unused-1',
);
}
public function testEtm(): void {

View File

@@ -166,6 +166,7 @@ class UserTest extends TestCase {
$this->assertEquals(0.0, $this->user->requiredRatio(), 'utest-required-ratio');
$this->assertEquals('', $this->user->forbiddenForumsList(), 'utest-forbidden-forum-list');
$this->assertEquals('', $this->user->referral(), 'utest-referral');
$this->assertEquals('??', $this->user->ipCountryIso(), 'utest-country-iso');
$this->assertEquals([], $this->user->tagSnatchCounts(), 'utest-tag-snatch-counts');
$this->assertEquals([], $this->user->tokenList(new Manager\Torrent(), 0, 0), 'utest-token-list');
$this->assertEquals([], $this->user->navigationList(), 'utest-navigation-list');