Files
ops-Gazelle/app/Tracker.php
2025-08-27 16:44:44 +02:00

340 lines
11 KiB
PHP

<?php
// TODO: The following actions are used, turn them into methods
// remove_torrent
// remove_users
namespace Gazelle;
use Gazelle\Enum\LeechType;
use Gazelle\Util\Curl;
use Gazelle\Util\Irc;
class Tracker extends Base {
protected static array $Requests = [];
protected string|false $error = false;
public function requestList(): array {
return self::$Requests;
}
public function lastError(): string|false {
return $this->error;
}
public function addToken(Torrent $torrent, User $user): bool {
return $this->update('add_token', [
'info_hash' => $torrent->infohashEncoded(),
'userid' => $user->id,
]);
}
public function removeToken(Torrent $torrent, User $user): bool {
return $this->update('remove_token', [
'info_hash' => $torrent->infohashEncoded(),
'userid' => $user->id,
]);
}
public function addTorrent(Torrent $torrent): bool {
return $this->update('add_torrent', [
'info_hash' => $torrent->flush()->infohashEncoded(),
'id' => $torrent->id(),
'freetorrent' => 0,
]);
}
public function modifyTorrent(TorrentAbstract $torrent, LeechType $leechType): bool {
return $this->update('update_torrent', [
'info_hash' => $torrent->infohashEncoded(),
'freetorrent' => $leechType->value
]);
}
public function modifyPasskey(string $old, string $new): bool {
return $this->update('change_passkey', [
'oldpasskey' => $old,
'newpasskey' => $new,
]);
}
public function addUser(User $user): bool {
self::$cache->increment('stats_user_count');
return $this->update('add_user', [
'passkey' => $user->announceKey(),
'id' => $user->id,
'visible' => $user->isVisible() ? '1' : '0',
]);
}
public function refreshUser(User $user): bool {
return $this->update('update_user', [
'passkey' => $user->announceKey(),
'can_leech' => $user->canLeech() ? '1' : '0',
'visible' => $user->isVisible() ? '1' : '0',
]);
}
public function traceUser(User $user, bool $trace): bool {
return $this->update('trace_user', [
'passkey' => $user->announceKey(),
'trace' => $trace ? '1' : '0',
]);
}
public function isTraced(User $user): bool {
return (bool)($this->userReport($user)['traced'] ?? false);
}
public function removeUser(User $user): bool {
return $this->update('remove_user', [
'passkey' => $user->announceKey(),
]);
}
public function addWhitelist(string $peer): bool {
return $this->update('add_whitelist', [
'peer_id' => $peer,
]);
}
public function modifyWhitelist(string $old, string $new): bool {
return $this->update('edit_whitelist', [
'old_peer_id' => $old,
'new_peer_id' => $new,
]);
}
public function removeWhitelist(string $peer): bool {
return $this->update('remove_whitelist', [
'peer_id' => $peer,
]);
}
public function modifyAnnounceInterval(int $interval): bool {
return $this->update('update_announce_interval', [
'new_announce_interval' => $interval,
]);
}
public function modifyAnnounceJitter(int $interval): bool {
return $this->update('update_announce_jitter', [
'new_announce_jitter' => $interval,
]);
}
public function expireFreeleechTokens(string $payload): int {
$clear = [];
$expire = [];
foreach (explode(',', $payload) as $item) {
[$userId, $torrentId] = array_map('intval', explode(':', $item));
if ($userId && $torrentId) {
$expire[] = [$userId, $torrentId];
$clear[$userId] = true;
}
}
if (!$expire) {
return 0;
}
self::$db->begin_transaction();
self::$db->prepared_query("
CREATE TEMPORARY TABLE expire_freeleech (
UserID int NOT NULL,
TorrentID int NOT NULL,
PRIMARY KEY (UserID, TorrentID)
)
");
self::$db->prepared_query("
INSERT IGNORE INTO expire_freeleech (UserID, TorrentID) VALUES
" . placeholders($expire, '(?, ?)'),
...array_merge(...$expire)
);
self::$db->prepared_query("
UPDATE users_freeleeches uf
INNER JOIN expire_freeleech ef USING (UserID, TorrentID)
SET
Expired = true
WHERE
Expired = false
");
$affected = self::$db->affected_rows();
self::$db->prepared_query("
DROP TABLE IF EXISTS expire_freeleech
");
self::$db->commit();
if (DEBUG_TRACKER_TOKEN_EXPIRE) {
$filename = (string)DEBUG_TRACKER_TOKEN_EXPIRE; // phpstan, grrr
$out = fopen($filename, 'a');
if ($out !== false) {
fprintf($out, "%s u=%d t=%d s=%s\n",
date('Y-m-d H:i:s'),
count($clear),
count($expire),
$payload,
);
fclose($out);
}
}
self::$cache->delete_multi(array_map(fn($id) => "users_tokens_$id", array_keys($clear)));
return $affected;
}
/**
* Send a GET request over a socket directly to the tracker
* For example, Tracker::update('change_passkey', array('oldpasskey' => OLD_PASSKEY, 'newpasskey' => NEW_PASSKEY)) will send the request:
* GET /tracker_32_char_secret_code/update?action=change_passkey&oldpasskey=OLD_PASSKEY&newpasskey=NEW_PASSKEY HTTP/1.1
*/
public function update(string $Action, array $Updates): bool {
if (DISABLE_TRACKER) {
return true;
}
// Build request
$url = "/update?action=$Action";
foreach ($Updates as $k => $v) {
$url .= "&$k=$v";
}
$this->error = false;
if ($this->request(TRACKER_SECRET, $url, 5) === false) {
if (self::$cache->get_value('ocelot_error_reported') === false) {
Irc::sendMessage(IRC_CHAN_DEV, "Failed to update ocelot: {$this->error} : $url");
self::$cache->cache_value('ocelot_error_reported', true, 180);
}
return false;
}
return true;
}
public function torrentReport(Torrent $torrent): array {
$result = $this->report("get=torrent&info_hash={$torrent->flush()->infohashEncoded()}");
if (empty($result)) {
return $result;
}
$result['last_flushed'] = date('Y-m-d H:i:s', $result['last_flushed']);
sort($result['fltoken_list']);
sort($result['leecher_list']);
sort($result['seeder_list']);
return $result;
}
/**
* Get user context from the tracker
*/
public function userReport(User $user): array {
return $this->report("get=user&key={$user->announceKey()}");
}
/**
* Get whatever info the tracker has to report
* returns an array of arrays of ['label' => '...', 'value' => ..., 'type' => (byte, elapsed, number, string)]
*/
public function info(): array {
return $this->report('get=stats');
}
/**
* Get whatever info the tracker has to report on memory allocations
*/
public function infoMemoryAlloc(): string {
return DISABLE_TRACKER
? "tracker is disabled by configuration"
: (string)$this->request(TRACKER_REPORTKEY, '/report?get=jemalloc', 3);
}
/**
* Send a stats request to the tracker and process the results
*
* @return array with stats in named keys or empty if the request failed
*/
protected function report(string $params): array {
if (DISABLE_TRACKER) {
return [];
}
$response = $this->request(TRACKER_REPORTKEY, "/report?$params", 5);
if ($response === false || $response === "") {
return [];
}
// on error the response will be bencoded data
return json_decode($response, true) ?? [];
}
/**
* Send a request to the tracker
*
* @return false|string tracker response message or false if the request failed
*/
protected function request(string $secret, string $path, int $maxAttempts): false|string {
if (DISABLE_TRACKER) {
return false;
}
$uri = "http://" . TRACKER_HOST . ":" . TRACKER_PORT . "/{$secret}{$path}";
$attempts = 0;
$success = false;
$startTime = microtime(true);
$data = null;
$responseCode = 0;
$sleep = 1200000; // 1200ms
$curl = new Curl()->setUseProxy(false)->addHeader('Host: ' . TRACKER_NAME);
while ($attempts++ < $maxAttempts) {
$result = $curl->fetch($uri);
$responseCode = $curl->responseCode();
if (!$result) {
if ($responseCode === 0) {
$this->error = "Failed to connect to ocelot.";
} else {
$this->error = "Ocelot responded with unexpected status code {$responseCode}";
}
usleep((int)$sleep);
$sleep *= 1.5; // exponential backoff
continue;
}
$data = $curl->result();
if ($responseCode === 200 || $data === "success") {
$success = true;
break;
} else {
usleep((int)$sleep);
$sleep *= 1.5; // exponential backoff
}
}
self::$Requests[] = [
'path' => $path,
'response' => $data,
'code' => $responseCode,
'status' => ($success ? 'ok' : 'failed'),
'time' => 1000 * (microtime(true) - $startTime),
];
if ($success) {
return $data;
}
return false;
}
public function delay(): array {
self::$db->prepared_query("
SELECT W.who, W.id, t.created
FROM (
SELECT 'gazelle' as who, max(id) as id from torrents
UNION
SELECT 'ocelot', max(fid) FROM xbt_files_users
) W
LEFT JOIN torrents t USING (id);
");
return self::$db->to_array(false, MYSQLI_ASSOC);
}
public function recentUpdate(int $seconds): bool {
return (bool)self::$db->scalar("
SELECT 1
FROM xbt_files_users
WHERE mtime > unix_timestamp(now() - INTERVAL ? SECOND)
LIMIT 1
", $seconds
);
}
}