seria/src/torrent.php
2022-07-03 23:44:11 +00:00

878 lines
32 KiB
PHP

<?php
class SeriaTorrentNotFoundException extends RuntimeException {}
class SeriaTorrentFileNotFoundException extends RuntimeException {}
class SeriaTorrentPieceNotFoundException extends RuntimeException {}
class SeriaTorrentDuplicateFileException extends RuntimeException {}
class SeriaTorrentCreateFailedException extends RuntimeException {}
class SeriaTorrentUpdateFailedException extends RuntimeException {}
class SeriaTorrentNukeFailedException extends RuntimeException {}
class SeriaTorrentPeerUpdateFailedException extends RuntimeException {}
class SeriaTorrentPeerDeleteFailedException extends RuntimeException {}
class SeriaTorrentPeerFetchFailedException extends RuntimeException {}
class SeriaTorrentPeerCreateFailedException extends RuntimeException {}
class SeriaTorrentBuilder {
private ?string $userId = null;
private string $name = '';
private int $created;
private int $pieceLength = 0;
private bool $isPrivate = false;
private string $comment = '';
private array $files = [];
private array $pieces = [];
public function __construct() {
$this->created = time();
}
public function setUser(SeriaUser $user): self {
return $this->setUserId($user->isLoggedIn() ? $user->getId() : null);
}
public function setUserId(?string $userId): self {
$this->userId = $userId;
return $this;
}
public function setName(string $name): self {
if(empty($name))
throw new InvalidArgumentException('$name may not be empty.');
$this->name = $name;
return $this;
}
public function setCreatedTime(int $created): self {
if($created < 0 && $created > 0x7FFFFFFF)
throw new InvalidArgumentException('$created is not a valid timestamp.');
$this->created = $created;
return $this;
}
public function setPieceLength(int $pieceLength): self {
if($pieceLength < 1)
throw new InvalidArgumentException('$pieceLength is not a valid piece length.');
$this->pieceLength = $pieceLength;
return $this;
}
public function setPrivate(bool $private): self {
$this->isPrivate = $private;
return $this;
}
public function setComment(string $comment): self {
$this->comment = $comment;
return $this;
}
public function addFile(string|array $path, int $length): self {
if(is_array($path))
$path = implode('/', $path);
$path = trim($path, '/');
if(array_key_exists($path, $this->files))
throw new SeriaTorrentDuplicateFileException('Duplicate file.');
$this->files[$path] = [
'length' => $length,
'path' => explode('/', $path),
];
return $this;
}
public function addPiece(string $hash): self {
if(strlen($hash) !== 20)
throw new InvalidArgumentException('$hash is not a valid piece hash.');
$this->pieces[] = $hash;
return $this;
}
public function calculateInfoHash(): string {
$info = [
'files' => array_values($this->files),
'name' => $this->name,
'piece length' => $this->pieceLength,
'pieces' => implode($this->pieces),
];
if(!empty($this->isPrivate))
$info['private'] = 1;
return hash('sha1', bencode($info), true);
}
public function create(PDO $pdo): SeriaTorrent {
$pdo->beginTransaction();
try {
$infoHash = $this->calculateInfoHash();
$insertTorrent = $pdo->prepare('INSERT INTO `ser_torrents` (`user_id`, `torrent_hash`, `torrent_name`, `torrent_created`, `torrent_piece_length`, `torrent_private`, `torrent_comment`) VALUES (:user, :info_hash, :name, FROM_UNIXTIME(:created), :piece_length, :private, :comment)');
$insertTorrentFile = $pdo->prepare('INSERT INTO `ser_torrents_files` (`torrent_id`, `file_length`, `file_path`) VALUES (:torrent, :length, :path)');
$insertTorrentPiece = $pdo->prepare('INSERT INTO `ser_torrents_pieces` (`torrent_id`, `piece_hash`) VALUES (:torrent, :hash)');
$insertTorrent->bindValue('user', $this->userId);
$insertTorrent->bindValue('info_hash', $infoHash);
$insertTorrent->bindValue('name', $this->name);
$insertTorrent->bindValue('created', $this->created);
$insertTorrent->bindValue('piece_length', $this->pieceLength);
$insertTorrent->bindValue('private', $this->isPrivate ? 1 : 0);
$insertTorrent->bindValue('comment', $this->comment);
if(!$insertTorrent->execute())
throw new SeriaTorrentCreateFailedException('Torrent insert query execution failed (duplicate?).');
$torrentId = $pdo->lastInsertId();
if($torrentId === false)
throw new SeriaTorrentCreateFailedException('Failed to grab torrent id.');
$insertTorrentFile->bindValue('torrent', $torrentId);
$insertTorrentPiece->bindValue('torrent', $torrentId);
foreach($this->files as $file) {
$insertTorrentFile->bindValue('length', $file['length']);
$insertTorrentFile->bindValue('path', implode('/', $file['path']));
if(!$insertTorrentFile->execute())
throw new SeriaTorrentCreateFailedException('Failed to insert torrent file.');
}
foreach($this->pieces as $piece) {
$insertTorrentPiece->bindValue('hash', $piece);
if(!$insertTorrentPiece->execute())
throw new SeriaTorrentCreateFailedException('Failed to insert torrent piece.');
}
$pdo->commit();
} catch(Exception $ex) {
$pdo->rollBack();
throw $ex;
}
return SeriaTorrent::byId($pdo, $torrentId);
}
public static function import(SeriaTorrent $torrent): self {
$builder = new static;
$builder->setUserId($torrent->getUserId());
$builder->setName($torrent->getName());
$builder->setPieceLength($torrent->getPieceLength());
$builder->setPrivate($torrent->isPrivate());
$builder->setCreatedTime($torrent->getCreatedTime());
$builder->setComment($torrent->getComment());
$pieces = $torrent->getPieces();
foreach($pieces as $piece)
$builder->addPiece($piece->getHash());
$files = $torrent->getFiles();
foreach($files as $file)
$builder->addFile($file->getPath(), $file->getLength());
return $builder;
}
public static function decode(mixed $source): self {
if(is_string($source) || is_resource($source))
$source = bdecode($source);
if(!isset($source['info']) || !is_array($source['info']))
throw new InvalidArgumentException('info key missing.');
if(!isset($source['info']['name']) || !is_string($source['info']['name']))
throw new InvalidArgumentException('info.name key missing.');
if(!isset($source['info']['files']) || !is_array($source['info']['files']))
throw new InvalidArgumentException('info.files key missing.');
if(!isset($source['info']['pieces']) || !is_string($source['info']['pieces']))
throw new InvalidArgumentException('info.pieces key missing.');
if(!isset($source['info']['piece length']) || !is_int($source['info']['piece length']))
throw new InvalidArgumentException('info.piece length key missing.');
$builder = new static;
$builder->setName($source['info']['name']);
$builder->setPieceLength($source['info']['piece length']);
$builder->setPrivate(!empty($source['info']['private']));
if(isset($source['creation date'])
&& is_int($source['creation date']))
$builder->setCreatedTime($source['creation date']);
if(!empty($source['comment']))
$builder->setComment($source['comment']);
foreach($source['info']['files'] as $file) {
if(empty($file)
|| !is_array($file)
|| !isset($file['length'])
|| !is_int($file['length'])
|| !isset($file['path'])
|| !is_array($file['path']))
throw new InvalidArgumentException('Invalid info.files entry.');
foreach($file['path'] as $pathPart)
if(!is_string($pathPart))
throw new InvalidArgumentException('Invalid info.files entry path.');
$builder->addFile($file['path'], $file['length']);
}
$pieces = str_split($source['info']['pieces'], 20);
foreach($pieces as $piece)
$builder->addPiece($piece);
return $builder;
}
}
class SeriaTorrent implements BEncodeSerializable {
private PDO $pdo;
private string $torrent_id;
private ?string $user_id;
private string $torrent_hash;
private int $torrent_active = 0;
private string $torrent_name;
private int $torrent_created;
private ?int $torrent_approved;
private int $torrent_piece_length;
private int $torrent_private;
private string $torrent_comment;
private int $peers_complete;
private int $peers_incomplete;
private int $torrent_size;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getId(): string {
return $this->torrent_id;
}
public function hasUser(): bool {
return $this->user_id !== null;
}
public function getUserId(): ?string {
return $this->user_id;
}
public function getHash(): string {
return $this->torrent_hash;
}
public function setHash(string $hash): self {
$this->torrent_hash = $hash;
return $this;
}
public function isActive(): bool {
return $this->torrent_active !== 0;
}
public function setActive(bool $active): self {
$this->torrent_active = $active ? 1 : 0;
return $this;
}
public function getName(): string {
return $this->torrent_name;
}
public function getCreatedTime(): int {
return $this->torrent_created;
}
public function getApprovedTime(): int {
return $this->torrent_approved ?? -1;
}
public function isApproved(): bool {
return $this->torrent_approved !== null;
}
public function approve(): void {
if(!$this->isApproved())
$this->torrent_approved = time();
}
public function getPieceLength(): int {
return $this->torrent_piece_length;
}
public function isPrivate(): bool {
return $this->torrent_private !== 0;
}
public function getComment(): string {
return $this->torrent_comment;
}
public function getFiles(): array {
return SeriaTorrentFile::byTorrent($this->pdo, $this);
}
public function getPieces(): array {
return SeriaTorrentPiece::byTorrent($this->pdo, $this);
}
public function getCompletePeers(): int {
return $this->peers_complete;
}
public function getIncompletePeers(): int {
return $this->peers_incomplete;
}
public function getSize(): int {
return $this->torrent_size;
}
public function toHTML(SeriaUser $user, string $class, bool $showSubmitter = true, ?string $verification = null): string {
$html = '<div class="tdl ' . $class . '" id="tdl' . $this->getId() . '">';
$html .= '<div class="tdl-details">';
$html .= '<div class="tdl-details-name"><a href="/info.php?id=' . $this->getId() . '">' . htmlspecialchars($this->getName()) . '</a></div>';
if($showSubmitter) {
try {
$submitter = SeriaUser::byId($this->pdo, $this->getUserId());
$html .= '<div class="tdl-user" style="--user-colour:' . (string)$submitter->getColour() . '">';
$html .= '<div class="avatar tdl-user-avatar"><a href="/profile.php?name=' . $submitter->getName() . '"><img src="' . sprintf(SERIA_AVATAR_FORMAT_RES, $submitter->getId(), 40) . '" alt="" width="20" height="20" /></a></div>';
$html .= '<div class="tdl-user-name"><a href="/profile.php?name=' . $submitter->getName() . '">' . $submitter->getName() . '</a></div>';
$html .= '</div>';
} catch(SeriaUserNotFoundException $ex) {}
}
$html .= '</div>';
$html .= '<div class="tdl-stats">';
$html .= '<div class="tdl-stats-uploading" title="Uploading"><div class="arrow">&#8593;</div><div class="number">' . number_format($this->getCompletePeers()) . '</div></div>';
$html .= '<div class="tdl-stats-downloading" title="Downloading"><div class="arrow">&#8595;</div><div class="number">' . number_format($this->getIncompletePeers()) . '</div></div>';
$html .= '</div>';
$html .= '<div class="tdl-actions">';
if(!$this->isApproved() && $user->canApproveTorrents() && $verification !== null) {
$html .= '<a href="/info.php?id=' . $this->getId() . '&amp;action=approve&amp;boob=' . $verification . '" title="Approve"><img src="//static.flash.moe/images/silk/tick.png" alt="Approve" /></a>';
$html .= '<a href="/info.php?id=' . $this->getId() . '&amp;action=deny&amp;boob=' . $verification . '" title="Deny"><img src="//static.flash.moe/images/silk/cross.png" alt="Deny" /></a>';
}
if($this->canDownload($user) === '') {
$html .= '<a href="/download.php?id=' . $this->getId() . '" title="Download"><img src="//static.flash.moe/images/silk/link.png" alt="Download" /></a>';
}
$html .= '</div>';
$html .= '</div>';
return $html;
}
public function getPeers(?SeriaTorrentPeer $exclude = null): array {
return SeriaTorrentPeer::byTorrent($this->pdo, $this, $exclude);
}
public function getSeeds(?SeriaTorrentPeer $exclude = null): array {
return SeriaTorrentPeer::byTorrent($this->pdo, $this, $exclude, true);
}
public function getInfo(): array {
$info = [
'files' => [],
'name' => $this->getName(),
'piece length' => $this->getPieceLength(),
'pieces' => '',
];
if($this->isPrivate())
$info['private'] = 1;
$files = $this->getFiles();
foreach($files as $file)
$info['files'][] = $file;
$pieces = $this->getPieces();
foreach($pieces as $piece)
$info['pieces'] .= $piece->getHash();
return $info;
}
public function canDownload(SeriaUser $user): string {
if(!$this->isActive())
return 'inactive';
if($this->isPrivate() && !$user->isLoggedIn())
return 'private';
if(!$this->isApproved() && (!$user->canApproveTorrents() && $this->getUserId() !== $user->getId()))
return 'pending';
return '';
}
public function bencodeSerialize(): mixed {
return [
'announce' => SERIA_ANNOUNCE_URL_ANON,
'created by' => 'Seria v' . SERIA_VERSION,
'creation date' => $this->getCreatedTime(),
'info' => $this->getInfo(),
];
}
public function encode(string $announceUrl): string {
$data = $this->bencodeSerialize();
$data['announce'] = $announceUrl;
return bencode($data);
}
public function update(): void {
$updateTorrent = $this->pdo->prepare('UPDATE `ser_torrents` SET `torrent_hash` = :hash, `torrent_active` = :active, `torrent_approved` = FROM_UNIXTIME(:approved) WHERE `torrent_id` = :torrent');
$updateTorrent->bindValue('hash', $this->torrent_hash);
$updateTorrent->bindValue('active', $this->torrent_active ? 1 : 0);
$updateTorrent->bindValue('approved', $this->torrent_approved);
$updateTorrent->bindValue('torrent', $this->torrent_id);
if(!$updateTorrent->execute())
throw new SeriaTorrentUpdateFailedException;
}
public function nuke(): void {
$nukeTorrent = $this->pdo->prepare('DELETE FROM `ser_torrents` WHERE `torrent_id` = :torrent');
$nukeTorrent->bindValue('torrent', $this->torrent_id);
if(!$nukeTorrent->execute())
throw new SeriaTorrentNukeFailedException;
}
public static function byHash(PDO $pdo, string $infoHash): SeriaTorrent {
$getTorrent = $pdo->prepare('SELECT `torrent_id`, `user_id`, `torrent_hash`, `torrent_active`, `torrent_name`, UNIX_TIMESTAMP(`torrent_created`) AS `torrent_created`, UNIX_TIMESTAMP(`torrent_approved`) AS `torrent_approved`, `torrent_piece_length`, `torrent_private`, `torrent_comment`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` = 0) AS `peers_complete`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` > 0) AS `peers_incomplete`, (SELECT SUM(tf.`file_length`) FROM `ser_torrents_files` AS tf WHERE tf.`torrent_id` = t.`torrent_id`) AS `torrent_size` FROM `ser_torrents` AS t WHERE `torrent_hash` = :info_hash');
$getTorrent->bindValue('info_hash', $infoHash);
if(!$getTorrent->execute())
throw new SeriaTorrentNotFoundException('Failed to execute hash query.');
$obj = $getTorrent->fetchObject(self::class, [$pdo]);
if($obj === false)
throw new SeriaTorrentNotFoundException('Hash not found.');
return $obj;
}
public static function byId(PDO $pdo, string $torrentId): SeriaTorrent {
$getTorrent = $pdo->prepare('SELECT `torrent_id`, `user_id`, `torrent_hash`, `torrent_active`, `torrent_name`, UNIX_TIMESTAMP(`torrent_created`) AS `torrent_created`, UNIX_TIMESTAMP(`torrent_approved`) AS `torrent_approved`, `torrent_piece_length`, `torrent_private`, `torrent_comment`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` = 0) AS `peers_complete`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` > 0) AS `peers_incomplete`, (SELECT SUM(tf.`file_length`) FROM `ser_torrents_files` AS tf WHERE tf.`torrent_id` = t.`torrent_id`) AS `torrent_size` FROM `ser_torrents` AS t WHERE `torrent_id` = :torrent');
$getTorrent->bindValue('torrent', $torrentId);
if(!$getTorrent->execute())
throw new SeriaTorrentNotFoundException('Failed to execute id query.');
$obj = $getTorrent->fetchObject(self::class, [$pdo]);
if($obj === false)
throw new SeriaTorrentNotFoundException('Id not found.');
return $obj;
}
public static function byUser(PDO $pdo, SeriaUser $user, int $startAt = -1, int $take = -1): array {
return self::all($pdo, false, true, $user, $startAt, $take);
}
public static function all(
PDO $pdo,
bool $publicOnly = true,
?bool $approved = true,
?SeriaUser $user = null,
int $startAt = -1,
int $take = -1
): array {
$hasUser = $user !== null;
$hasApproved = $approved !== null;
$hasStartAt = $startAt >= 0;
$hasTake = $take > 0;
$query = 'SELECT `torrent_id`, `user_id`, `torrent_hash`, `torrent_active`, `torrent_name`, UNIX_TIMESTAMP(`torrent_created`) AS `torrent_created`, UNIX_TIMESTAMP(`torrent_approved`) AS `torrent_approved`, `torrent_piece_length`, `torrent_private`, `torrent_comment`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` = 0) AS `peers_complete`, (SELECT COUNT(*) FROM `ser_torrents_peers` AS tp WHERE tp.`torrent_id` = t.`torrent_id` AND `peer_left` > 0) AS `peers_incomplete`, (SELECT SUM(tf.`file_length`) FROM `ser_torrents_files` AS tf WHERE tf.`torrent_id` = t.`torrent_id`) AS `torrent_size` FROM `ser_torrents` AS t WHERE `torrent_active` <> 0';
if($publicOnly)
$query .= ' AND `torrent_private` = 0';
if($hasUser)
$query .= ' AND `user_id` = :user';
if($hasApproved)
$query .= ' AND `torrent_approved` IS' . ($approved ? ' NOT ' : ' ') . 'NULL';
if($hasStartAt)
$query .= ' AND `torrent_id` < :start';
$query .= ' ORDER BY `torrent_id` DESC';
if($hasTake)
$query .= ' LIMIT :take';
$getTorrents = $pdo->prepare($query);
if($hasUser)
$getTorrents->bindValue('user', $user->getId());
if($hasStartAt)
$getTorrents->bindValue('start', $startAt);
if($hasTake)
$getTorrents->bindValue('take', $take);
if(!$getTorrents->execute())
throw new SeriaTorrentNotFoundException('Failed to execute user query.');
$objs = [];
while(($obj = $getTorrents->fetchObject(self::class, [$pdo])) !== false)
$objs[] = $obj;
return $objs;
}
}
class SeriaTorrentFile implements BEncodeSerializable {
private PDO $pdo;
private ?string $file_id = null;
private ?string $torrent_id = null;
private int $file_length;
private string $file_path;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getId(): ?string {
return $this->file_id;
}
public function getTorrentId(): ?string {
return $this->torrent_id;
}
public function getLength(): int {
return $this->file_length;
}
public function getPath(): string {
return $this->file_path;
}
public function bencodeSerialize(): mixed {
return [
'length' => $this->getLength(),
'path' => explode('/', $this->getPath()),
];
}
public static function byTorrent(PDO $pdo, SeriaTorrent $torrent): array {
$getFiles = $pdo->prepare('SELECT `file_id`, `torrent_id`, `file_length`, `file_path` FROM `ser_torrents_files` WHERE `torrent_id` = :torrent ORDER BY `file_id` ASC');
$getFiles->bindValue('torrent', $torrent->getId());
if(!$getFiles->execute())
throw new SeriaTorrentFileNotFoundException('Failed to fetch torrent files.');
$files = [];
while(($obj = $getFiles->fetchObject(self::class, [$pdo])) !== false)
$files[] = $obj;
return $files;
}
}
class SeriaTorrentPiece {
private PDO $pdo;
private ?string $piece_id = null;
private ?string $torrent_id = null;
private string $piece_hash;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getId(): ?string {
return $this->piece_id;
}
public function getTorrentId(): ?string {
return $this->torrent_id;
}
public function getHash(): string {
return $this->piece_hash;
}
public static function byTorrent(PDO $pdo, SeriaTorrent $torrent): array {
$getPieces = $pdo->prepare('SELECT `piece_id`, `torrent_id`, `piece_hash` FROM `ser_torrents_pieces` WHERE `torrent_id` = :torrent ORDER BY `piece_id` ASC');
$getPieces->bindValue('torrent', $torrent->getId());
if(!$getPieces->execute())
throw new SeriaTorrentPieceNotFoundException('Failed to fetch torrent pieces.');
$pieces = [];
while(($obj = $getPieces->fetchObject(self::class, [$pdo])) !== false)
$pieces[] = $obj;
return $pieces;
}
}
class SeriaTorrentPeer implements BEncodeSerializable {
private PDO $pdo;
private string $peer_id;
private string $torrent_id;
private ?string $user_id;
private string $peer_address;
private int $peer_port;
private int $peer_updated;
private int $peer_expires;
private string $peer_agent;
private string $peer_key;
private int $peer_uploaded;
private int $peer_downloaded;
private int $peer_left;
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function getId(): string {
return $this->peer_id;
}
public function getTorrentId(): string {
return $this->torrent_id;
}
public function getUserId(): ?string {
return $this->user_id;
}
public function hasUserId(): bool {
return !empty($this->user_id);
}
public function getUser(): SeriaUser {
if($this->user_id === null)
return SeriaUser::anonymous();
return SeriaUser::byId($this->pdo, $this->user_id);
}
public function getAddress(): string {
return $this->peer_address;
}
public function verifyUser(SeriaUser $user): bool {
return !$this->hasUserId()
|| ($user->isLoggedIn() && $user->getId() === $this->user_id);
}
public function getAddressRaw(): string {
return inet_pton($this->peer_address);
}
public function getPort(): int {
return $this->peer_port;
}
public function getUpdatedTime(): int {
return $this->peer_updated;
}
public function getExpiresTime(): int {
return $this->peer_expires;
}
public function getAgent(): string {
return $this->peer_agent;
}
public function getKey(): string {
return $this->peer_key;
}
public function getBytesUploaded(): int {
return $this->peer_uploaded;
}
public function getBytesDownloaded(): int {
return $this->peer_downloaded;
}
public function getBytesRemaining(): int {
return $this->peer_left;
}
public function isSeed(): bool {
return $this->peer_left === 0;
}
public function isLeech(): bool {
return $this->peer_left > 0;
}
public function verifyKey(string $key): bool {
return hash_equals($this->getKey(), $key);
}
public function update(
SeriaUser $user,
string $remoteAddr,
int $remotePort,
int $interval,
string $peerAgent,
int $bytesUploaded,
int $bytesDownloaded,
int $bytesRemaining
): void {
$updatePeer = $this->pdo->prepare('UPDATE `ser_torrents_peers` SET `user_id` = :user, `peer_address` = INET6_ATON(:address), `peer_port` = :port, `peer_updated` = NOW(), `peer_expires` = NOW() + INTERVAL :interval SECOND, `peer_agent` = :agent, `peer_uploaded` = :uploaded, `peer_downloaded` = :downloaded, `peer_left` = :remaining WHERE `torrent_id` = :torrent AND `peer_id` = :peer');
$updatePeer->bindValue('torrent', $this->getTorrentId());
$updatePeer->bindValue('user', $user->isLoggedIn() ? $user->getId() : null);
$updatePeer->bindValue('peer', $this->getId());
$updatePeer->bindValue('address', $this->peer_address = $remoteAddr);
$updatePeer->bindValue('port', $this->peer_port = ($remotePort & 0xFFFF));
$updatePeer->bindValue('interval', $interval);
$updatePeer->bindValue('agent', $this->peer_agent = $peerAgent);
$updatePeer->bindValue('uploaded', $this->peer_uploaded = $bytesUploaded);
$updatePeer->bindValue('downloaded', $this->peer_downloaded = $bytesDownloaded);
$updatePeer->bindValue('remaining', $this->peer_left = $bytesRemaining);
if(!$updatePeer->execute())
throw new SeriaTorrentPeerUpdateFailedException;
$this->peer_updated = time();
$this->peer_expires = $this->peer_updated + $interval;
}
public function delete(): void {
$deletePeer = $this->pdo->prepare('DELETE FROM `ser_torrents_peers` WHERE `torrent_id` = :torrent AND `peer_id` = :peer');
$deletePeer->bindValue('torrent', $this->getTorrentId());
$deletePeer->bindValue('peer', $this->getId());
if(!$deletePeer->execute())
throw new SeriaTorrentPeerDeleteFailedException;
}
public function encodeInfo(bool $includeId): mixed {
$info = [
'ip' => $this->getAddress(),
'port' => $this->getPort(),
];
if($includeId)
$info['peer id'] = $this->getId();
return $info;
}
public function encode(bool $includeId): string {
return bencode($this->encodeInfo($includeId));
}
public function bencodeSerialize(): mixed {
return $this->encodeInfo(false);
}
public static function countUserStats(PDO $pdo, SeriaUser $user): stdClass {
$countActive = $pdo->prepare('SELECT :user AS `user`, (SELECT COUNT(*) FROM `ser_torrents_peers` WHERE `user_id` = `user` AND `peer_left` <> 0) AS `user_downloading`, (SELECT COUNT(*) FROM `ser_torrents_peers` WHERE `user_id` = `user` AND `peer_left` = 0) AS `user_uploading`');
$countActive->bindValue('user', $user->getId());
$countActive->execute();
$counts = $countActive->fetchObject();
if($counts === false)
$counts = (object)[
'user_downloading' => 0,
'user_uploading' => 0,
];
else
unset($counts->user);
return $counts;
}
public static function byTorrent(
PDO $pdo,
SeriaTorrent $torrent,
?SeriaTorrentPeer $exclude = null,
?bool $complete = null
): array {
$hasExclude = $exclude !== null;
$hasComplete = $complete !== null;
$query = 'SELECT `peer_id`, `torrent_id`, `user_id`, INET6_NTOA(`peer_address`) AS `peer_address`, `peer_port`, UNIX_TIMESTAMP(`peer_updated`) AS `peer_updated`, UNIX_TIMESTAMP(`peer_expires`) AS `peer_expires`, `peer_agent`, `peer_key`, `peer_uploaded`, `peer_downloaded`, `peer_left` FROM `ser_torrents_peers` WHERE `torrent_id` = :torrent';
if($hasExclude)
$query .= ' AND `peer_id` <> :peer';
if($hasComplete)
$query .= ' AND `peer_left` ' . ($complete ? '=' : '<>') . ' 0';
$getPeers = $pdo->prepare($query);
$getPeers->bindValue('torrent', $torrent->getId());
if($hasExclude)
$getPeers->bindValue('peer', $exclude->getId());
if(!$getPeers->execute())
throw new SeriaTorrentPeerFetchFailedException('Failed to fetch peers by torrent.');
$objs = [];
while(($obj = $getPeers->fetchObject(self::class, [$pdo])) !== false)
$objs[] = $obj;
return $objs;
}
public static function byPeerId(PDO $pdo, SeriaTorrent $torrent, string $peerId): ?SeriaTorrentPeer {
$getPeer = $pdo->prepare('SELECT `peer_id`, `torrent_id`, `user_id`, INET6_NTOA(`peer_address`) AS `peer_address`, `peer_port`, UNIX_TIMESTAMP(`peer_updated`) AS `peer_updated`, UNIX_TIMESTAMP(`peer_expires`) AS `peer_expires`, `peer_agent`, `peer_key`, `peer_uploaded`, `peer_downloaded`, `peer_left` FROM `ser_torrents_peers` WHERE `torrent_id` = :torrent AND `peer_id` = :peer');
$getPeer->bindValue('torrent', $torrent->getId());
$getPeer->bindValue('peer', $peerId);
if(!$getPeer->execute())
throw new SeriaTorrentPeerFetchFailedException('Failed to fetch peers by peer.');
return ($obj = $getPeer->fetchObject(self::class, [$pdo])) ? $obj : null;
}
public static function create(
PDO $pdo,
SeriaTorrent $torrent,
SeriaUser $user,
string $peerId,
string $remoteAddr,
int $remotePort,
int $interval,
string $peerAgent,
string $peerKey,
int $bytesUploaded,
int $bytesDownloaded,
int $bytesRemaining
): SeriaTorrentPeer {
$insertPeer = $pdo->prepare('INSERT INTO `ser_torrents_peers` (`peer_id`, `torrent_id`, `user_id`, `peer_address`, `peer_port`, `peer_updated`, `peer_expires`, `peer_agent`, `peer_key`, `peer_uploaded`, `peer_downloaded`, `peer_left`) VALUES (:id, :torrent, :user, INET6_ATON(:address), :port, NOW(), NOW() + INTERVAL :interval SECOND, :agent, :key, :uploaded, :downloaded, :remaining)');
$insertPeer->bindValue('id', $peerId);
$insertPeer->bindValue('torrent', $torrent->getId());
$insertPeer->bindValue('user', $user->isLoggedIn() ? $user->getId() : null);
$insertPeer->bindValue('address', $remoteAddr);
$insertPeer->bindValue('port', $remotePort & 0xFFFF);
$insertPeer->bindValue('interval', $interval);
$insertPeer->bindValue('agent', $peerAgent);
$insertPeer->bindValue('key', $peerKey);
$insertPeer->bindValue('uploaded', $bytesUploaded);
$insertPeer->bindValue('downloaded', $bytesDownloaded);
$insertPeer->bindValue('remaining', $bytesRemaining);
if(!$insertPeer->execute())
throw new SeriaTorrentPeerCreateFailedException('Query failed.');
$peer = self::byPeerId($pdo, $torrent, $peerId);
if($peer === null)
throw new SeriaTorrentPeerCreateFailedException('Fetch failed.');
return $peer;
}
public static function deleteExpired(PDO $pdo): void {
$pdo->exec('DELETE FROM `ser_torrents_peers` WHERE `peer_expires` < NOW()');
}
}