878 lines
32 KiB
PHP
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">↑</div><div class="number">' . number_format($this->getCompletePeers()) . '</div></div>';
|
|
$html .= '<div class="tdl-stats-downloading" title="Downloading"><div class="arrow">↓</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() . '&action=approve&boob=' . $verification . '" title="Approve"><img src="//static.flash.moe/images/silk/tick.png" alt="Approve" /></a>';
|
|
$html .= '<a href="/info.php?id=' . $this->getId() . '&action=deny&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()');
|
|
}
|
|
}
|