From c593bfee53a671a48cdb12bbaebe9cd3f11e9790 Mon Sep 17 00:00:00 2001 From: flashwave Date: Sun, 3 Jul 2022 23:44:11 +0000 Subject: [PATCH] Import existing stuff. --- .gitattributes | 1 + .gitignore | 3 + public/_footer.php | 18 + public/_header.php | 52 +++ public/announce.php | 144 +++++++ public/available.php | 62 +++ public/create.php | 88 +++++ public/download.php | 37 ++ public/history.php | 27 ++ public/index.php | 13 + public/info.php | 154 ++++++++ public/pending.php | 55 +++ public/profile.php | 71 ++++ public/robots.txt | 2 + public/seria.css | 726 +++++++++++++++++++++++++++++++++++ public/settings.php | 15 + public/test.php | 41 ++ seria.php | 164 ++++++++ src/announce.php | 51 +++ src/benben.php | 146 +++++++ src/torrent.php | 877 +++++++++++++++++++++++++++++++++++++++++++ src/user.php | 217 +++++++++++ 22 files changed, 2964 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 public/_footer.php create mode 100644 public/_header.php create mode 100644 public/announce.php create mode 100644 public/available.php create mode 100644 public/create.php create mode 100644 public/download.php create mode 100644 public/history.php create mode 100644 public/index.php create mode 100644 public/info.php create mode 100644 public/pending.php create mode 100644 public/profile.php create mode 100644 public/robots.txt create mode 100644 public/seria.css create mode 100644 public/settings.php create mode 100644 public/test.php create mode 100644 seria.php create mode 100644 src/announce.php create mode 100644 src/benben.php create mode 100644 src/torrent.php create mode 100644 src/user.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cba3814 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.debug +/config.php +/errors.log diff --git a/public/_footer.php b/public/_footer.php new file mode 100644 index 0000000..d60aa8d --- /dev/null +++ b/public/_footer.php @@ -0,0 +1,18 @@ + + + + + + diff --git a/public/_header.php b/public/_header.php new file mode 100644 index 0000000..a1bf855 --- /dev/null +++ b/public/_header.php @@ -0,0 +1,52 @@ +isLoggedIn(); +?> + + + + + <?=$tPageTitle;?> + + + +
+ +
diff --git a/public/announce.php b/public/announce.php new file mode 100644 index 0000000..0c2dd8d --- /dev/null +++ b/public/announce.php @@ -0,0 +1,144 @@ + $reason])); +} + +if(empty($_GET)) + die('There is nothing here.'); + +header('Content-Type: text/plain; charset=us-ascii'); +header('X-Tracker-Version: Seria/' . SERIA_VERSION); + +if(!$sIsIPv4) + announce_fail('Tracker is only supported over IPv4, please reset your DNS cache.'); + +//file_put_contents(SERIA_ERRORS, $_SERVER['REQUEST_URI'] . PHP_EOL, FILE_APPEND); + +$urlParts = explode('/', trim($_SERVER['PATH_INFO'], '/')); + +$cInfoHash = (string)filter_input(INPUT_GET, 'info_hash'); +if(strlen($cInfoHash) !== 20) + announce_fail('Invalid info hash.'); + +$cPeerId = (string)filter_input(INPUT_GET, 'peer_id'); +if(strlen($cPeerId) !== 20) + announce_fail('Invalid peer id format.'); + +$cPeerAddress = $_SERVER['REMOTE_ADDR']; + +$cPeerPort = (int)filter_input(INPUT_GET, 'port', FILTER_SANITIZE_NUMBER_INT); +if($cPeerPort < 1 || $cPeerPort > 0xFFFF) + announce_fail('Invalid port number.'); + +$cPeerKey = (string)filter_input(INPUT_GET, 'key'); +if(strlen($cPeerKey) > 128) + announce_fail('Key is ridiculous.'); + +$cPeerEvent = (string)filter_input(INPUT_GET, 'event'); +if(strlen($cPeerEvent) > 128) + announce_fail('Event is fucked up.'); + +$cPeerAgent = (string)filter_input(INPUT_SERVER, 'HTTP_USER_AGENT'); +if(strlen($cPeerAgent) > 255) + announce_fail('Agent is stupid.'); + +$cCompactPeers = !empty($_GET['compact']); +$cNoPeerId = !empty($_GET['no_peer_id']); +$cShortAnnounce = !empty($_GET['short']); + +$cBytesUploaded = (int)filter_input(INPUT_GET, 'uploaded', FILTER_SANITIZE_NUMBER_INT); +$cBytesDownloaded = (int)filter_input(INPUT_GET, 'downloaded', FILTER_SANITIZE_NUMBER_INT); +$cBytesRemaining = (int)filter_input(INPUT_GET, 'left', FILTER_SANITIZE_NUMBER_INT); +$cPeerWant = (int)filter_input(INPUT_GET, 'numwant', FILTER_SANITIZE_NUMBER_INT); + +$cPassKey = $urlParts[0] ?? ''; +if(!empty($cPassKey)) { + try { + $cUserInfo = SeriaUser::byPassKey($pdo, $cPassKey); + } catch(SeriaUserNotFoundException $ex) { + sleep(3); + announce_fail('Authentication failed.'); + } +} else $cUserInfo = SeriaUser::anonymous(); + +$interval = SERIA_ANNOUNCE_INTERVAL; +$minInterval = SERIA_ANNOUNCE_INTERVAL_MIN; + +if($cShortAnnounce && SERIA_ANNOUNCE_SHORT) { + $interval = SERIA_ANNOUNCE_SHORT_INTERVAL; + $minInterval = SERIA_ANNOUNCE_SHORT_INTERVAL_MIN; +} + +try { + $cTorrentInfo = SeriaTorrent::byHash($pdo, $cInfoHash); +} catch(SeriaTorrentNotFoundException $ex) { + announce_fail('Info hash not found.'); +} + +$cCanDownload = $cTorrentInfo->canDownload($cUserInfo); +if($cCanDownload !== '') + switch($cCanDownload) { + case 'inactive': + announce_fail('This download is inactive.'); + case 'private': + announce_fail('You must be logged in for this download.'); + case 'pending': + announce_fail('This download is pending approval.'); + default: + announce_fail($cCanDownload); + } + +$cPeerInfo = SeriaTorrentPeer::byPeerId($pdo, $cTorrentInfo, $cPeerId); + +if(empty($cPeerInfo)) { + // could probably skip this is the event is 'stopped' + $cPeerInfo = SeriaTorrentPeer::create( + $pdo, $cTorrentInfo, $cUserInfo, $cPeerId, $cPeerAddress, $cPeerPort, $interval, + $cPeerAgent, $cPeerKey, $cBytesUploaded, $cBytesDownloaded, $cBytesRemaining + ); +} else { + if(!$cPeerInfo->verifyKey($cPeerKey)) { + sleep(3); + announce_fail('Peer verification failed.'); + } + + if(!$cPeerInfo->verifyUser($cUserInfo)) { + sleep(3); + announce_fail('User verification failed.'); + } + + $cUserInfo->incrementTransferCounts( + $cBytesDownloaded - $cPeerInfo->getBytesDownloaded(), + $cBytesUploaded - $cPeerInfo->getBytesUploaded() + ); + + $cPeerInfo->update( + $cUserInfo, $cPeerAddress, $cPeerPort, $interval, $cPeerAgent, + $cBytesUploaded, $cBytesDownloaded, $cBytesRemaining + ); +} + +if($cPeerEvent === 'stopped') { + $cPeerInfo->delete(); + die(bencode(new SeriaAnnounceResponse)); +} + +SeriaTorrentPeer::deleteExpired($pdo); + +$response = new SeriaAnnounceResponse( + $interval + mt_rand(0, 10), + $minInterval, + $cTorrentInfo, + $cNoPeerId && SERIA_ANNOUNCE_NO_PEER_ID, + $cCompactPeers +); + +if(!$cPeerInfo->isSeed() || !SERIA_ANNOUNCE_NO_SEED_P2P) { + $peers = $cTorrentInfo->getSeeds($cPeerInfo); + foreach($peers as $peer) + $response->addPeer($peer); +} + +echo bencode($response); diff --git a/public/available.php b/public/available.php new file mode 100644 index 0000000..363f1ea --- /dev/null +++ b/public/available.php @@ -0,0 +1,62 @@ +getName(); +else + $aPageTitle = 'Available Downloads'; + +$tPageTitle = $aPageTitle; + +$aPageUrl = '/available.php?'; +if($aUserInfo !== null) + $aPageUrl .= 'name=' . $aUserInfo->getName() . '&'; + +$aStartAt = -1; +$aTake = 20; + +if(isset($_GET['start'])) + $aStartAt = (int)filter_input(INPUT_GET, 'start', FILTER_SANITIZE_NUMBER_INT); + +$aTorrents = SeriaTorrent::all($pdo, !$sUserInfo->isLoggedIn(), true, $aUserInfo, $aStartAt, $aTake); + +require_once __DIR__ . '/_header.php'; + +echo '
'; + +echo '
' . $aPageTitle . '
'; + +echo '
'; + +$aShowMore = false; +$aLastId = 0; + +if(empty($aTorrents)) { + echo '
Sorry, nothing.
'; +} else { + foreach($aTorrents as $torrent) { + echo $torrent->toHTML($sUserInfo, 'downloads-item'); + $aLastId = $torrent->getId(); + } + + $aShowMore = count($aTorrents) === $aTake; +} + +echo '
'; + +if($aShowMore) + echo ''; + +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/create.php b/public/create.php new file mode 100644 index 0000000..1dd2441 --- /dev/null +++ b/public/create.php @@ -0,0 +1,88 @@ +isLoggedIn()) { + http_response_code(403); + die('You must be logged in to view this page.'); +} + +if(!$sUserInfo->canCreateTorrents()) { + http_response_code(403); + die('You are not allowed to view this page.'); +} + +if(!empty($_FILES['torrent'])) { + if(empty($_POST['boob']) || !hash_equals($sVerification, (string)filter_input(INPUT_POST, 'boob'))) { + $cError = 'Request verification failed.'; + } else { + if(!isset($_FILES['torrent']['error']) || is_array($_FILES['torrent']['error'])) { + $cError = 'Invalid parameters.'; + } else { + if($_FILES['torrent']['error'] !== UPLOAD_ERR_OK) { + $cError = [ + UPLOAD_ERR_NO_FILE => 'No file was sent.', + UPLOAD_ERR_INI_SIZE => 'File size limit exceeded.', + UPLOAD_ERR_FORM_SIZE => 'File size limit exceeded.', + ][$_FILES['torrent']['error']] ?? 'An unexpected error occurred.'; + } else { + if(!is_uploaded_file($_FILES['torrent']['tmp_name'])) { + $cError = 'File was not an uploaded file(?).'; + } else { + $torrentFile = fopen($_FILES['torrent']['tmp_name'], 'rb'); + try { + $cTorrentBuilder = SeriaTorrentBuilder::decode($torrentFile); + $cTorrentBuilder->setUser($sUserInfo); + $cTorrentInfo = $cTorrentBuilder->create($pdo); + header('Location: /info.php?id=' . $cTorrentInfo->getId()); + exit; + } catch(Exception $ex) { + $cError = $ex->getMessage(); + if(empty($cError)) + $cError = (string)$ex; + } finally { + fclose($torrentFile); + } + } + } + } + } +} + +$tPageTitle = 'Create Torrent'; + +$cTrackerUrl = 'https://' . $_SERVER['HTTP_HOST'] . '/announce.php/' . $sUserInfo->getPassKey(); + +require_once __DIR__ . '/_header.php'; + +echo '
'; + +echo '
'; +echo '

Creating a torrent

'; +echo '

Here you can submit a torrent for tracking.

'; +echo '

You can use any client for submission. This page does not contain any of its own fields, the creator in your client should be sufficient.

'; +echo '

Use as the tracker url so you\'re immediately seeding the torrent, the tracker will error but that\'s temporary since the torrent has not been submitted yet. This URL contains your private pass key, do not share it.

'; +echo '

After your torrent has been submitted it will be queued for approval and won\'t immediately be available.

'; +echo '
'; + +if(!empty($cError)) { + echo '
'; + echo '
Error
'; + echo '
'; + echo htmlspecialchars($cError); + echo '
'; + echo '
'; +} + +echo '
'; +echo '
'; + +echo ''; +echo ''; +echo ''; + +echo '
'; +echo '
'; + +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/download.php b/public/download.php new file mode 100644 index 0000000..686f89f --- /dev/null +++ b/public/download.php @@ -0,0 +1,37 @@ +canDownload($sUserInfo); +if($dCanDownload !== '') { + http_response_code(403); + switch($dCanDownload) { + case 'inactive': + die('This download is inactive.'); + case 'private': + die('You must be logged in for this download.'); + case 'pending': + die('This download is pending approval.'); + default: + die($dCanDownload); + } +} + +$trackerUrl = SERIA_ANNOUNCE_URL_ANON; +if($sUserInfo->isLoggedIn()) + $trackerUrl = sprintf(SERIA_ANNOUNCE_URL, $sUserInfo->getPassKey($pdo)); + +header('Content-Type: application/x-bittorrent'); +header('Content-Disposition: inline; filename="' . htmlspecialchars($dTorrentInfo->getName()) . '.torrent"'); + +echo $dTorrentInfo->encode($trackerUrl); diff --git a/public/history.php b/public/history.php new file mode 100644 index 0000000..274ef10 --- /dev/null +++ b/public/history.php @@ -0,0 +1,27 @@ +isLoggedIn()) { + http_response_code(404); + die('You must be logged in to view this page.'); +} + +if(isset($_GET['name'])) { + $aUserName = (string)filter_input(INPUT_GET, 'name'); + + try { + $aUserInfo = SeriaUser::byName($pdo, $aUserName); + } catch(SeriaUserNotFoundException $ex) { + http_response_code(404); + die('User not found.'); + } +} else $aUserInfo = $sUserInfo; + +$tPageTitle = 'Transfer History'; + +require_once __DIR__ . '/_header.php'; + +echo 'Transfer history should be listed here
'; +var_dump($aUserInfo->getName()); + +require_once __DIR__ . '/_footer.php'; diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..b70baa6 --- /dev/null +++ b/public/index.php @@ -0,0 +1,13 @@ +'; +echo '

Welcome to the ' . SERIA_FLASHII . ' Tracker!

'; +echo '

This tracker is provided as a central download repository for files relevant to the community.

'; +echo '

Among intended uses are archives and modifications. Some downloads are only available to authenticated users. Stats only function as Internet Points and won\'t incur repercussions if bad. Certain users are able to submit downloads for approval.

'; +echo '

Please enjoy responsibly!

'; +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/info.php b/public/info.php new file mode 100644 index 0000000..00d589c --- /dev/null +++ b/public/info.php @@ -0,0 +1,154 @@ +canDownload($sUserInfo); +if($dCanDownload !== '') { + http_response_code(403); + switch($dCanDownload) { + case 'inactive': + die('This download is inactive.'); + case 'private': + die('You must be logged in for this download.'); + case 'pending': + die('This download is pending approval.'); + default: + die($dCanDownload); + } +} + +if(isset($_GET['action'])) { + if(empty($_GET['boob']) || !hash_equals($sVerification, (string)filter_input(INPUT_GET, 'boob'))) { + header('Location: /info.php?id=' . $dTorrentInfo->getId()); + exit; + } + + switch(filter_input(INPUT_GET, 'action')) { + case 'recalculate-info-hash': + if($sUserInfo->canRecalculateInfoHash()) { + $builder = SeriaTorrentBuilder::import($dTorrentInfo); + $dTorrentInfo->setHash($builder->calculateInfoHash()); + $dTorrentInfo->update(); + } + + header('Location: /info.php?id=' . $dTorrentInfo->getId()); + exit; + + case 'approve': + if($sUserInfo->canApproveTorrents()) { + $dTorrentInfo->approve(); + $dTorrentInfo->update(); + } + + header('Location: /info.php?id=' . $dTorrentInfo->getId()); + exit; + + case 'deny': + if($sUserInfo->canApproveTorrents()) + $dTorrentInfo->nuke(); + + header('Location: /pending.php'); + exit; + } +} + +$tPageTitle = $dTorrentInfo->getName(); + +require_once __DIR__ . '/_header.php'; + +echo '
'; + +echo '
'; +echo '

' . htmlspecialchars($dTorrentInfo->getName()) . '

'; +echo ''; +echo '
'; + +echo '
'; + +echo ''; + +if($sUserInfo->canApproveTorrents() && $dTorrentInfo->isApproved()) { + echo '
'; + echo '
Approved on
'; + echo '
'; + echo '
'; +} + +echo '
'; +echo '
Total size
'; +echo '
' . byte_symbol($dTorrentInfo->getSize()) . ' (' . number_format($dTorrentInfo->getSize()) . ' bytes)
'; +echo '
'; + +echo '
'; +echo '
Uploading
'; +echo '
' . number_format($dTorrentInfo->getCompletePeers()) . '
'; +echo '
'; + +echo '
'; +echo '
Downloading
'; +echo '
' . number_format($dTorrentInfo->getIncompletePeers()) . '
'; +echo '
'; + +if($dTorrentInfo->isPrivate()) { + echo '
'; + echo '
Visibility
'; + echo '
Private
'; + echo '
'; +} + +echo '
'; + +if($dTorrentInfo->hasUser()) { + try { + $dUserInfo = SeriaUser::byId($pdo, $dTorrentInfo->getUserId()); + + echo '
'; + echo '
Submitted by
'; + echo '
'; + echo ''; + echo '
'; + } catch(SeriaUserNotFoundException $ex) {} +} + +if(!$dTorrentInfo->isApproved() && $sUserInfo->canApproveTorrents()) { + echo '
'; + + echo '
This torrent is pending approval.
'; + echo 'APPROVE'; + echo 'DENY'; + + echo '
'; +} + +$dComment = $dTorrentInfo->getComment(); + +if(!empty($dComment)) { + echo '
'; + echo '
Description
'; + echo '
';
+    echo htmlspecialchars($dComment);
+    echo '
'; + echo '
'; +} + +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/pending.php b/public/pending.php new file mode 100644 index 0000000..497806a --- /dev/null +++ b/public/pending.php @@ -0,0 +1,55 @@ +isLoggedIn()) { + http_response_code(403); + die('You must be logged in to view this page.'); +} + +if(!$sUserInfo->canApproveTorrents()) { + http_response_code(403); + die('You are not allowed to view this page.'); +} + +$tPageTitle = 'Pending Torrents'; + +$pPageUrl = '/pending.php?'; + +$pStartAt = -1; +$pTake = 20; + +if(isset($_GET['start'])) + $pStartAt = (int)filter_input(INPUT_GET, 'start', FILTER_SANITIZE_NUMBER_INT); + +$pTorrents = SeriaTorrent::all($pdo, false, false, null, $pStartAt, $pTake); + +require_once __DIR__ . '/_header.php'; + +echo '
'; + +echo '
Pending Torrents
'; + +echo '
'; + +$pShowMore = false; +$pLastId = 0; + +if(empty($pTorrents)) { + echo '
There are no pending torrents!
'; +} else { + foreach($pTorrents as $torrent) { + echo $torrent->toHTML($sUserInfo, 'downloads-item', true, $sVerification); + $pLastId = $torrent->getId(); + } + + $pShowMore = count($pTorrents) === $pTake; +} + +echo '
'; + +if($pShowMore) + echo ''; + +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/profile.php b/public/profile.php new file mode 100644 index 0000000..9281356 --- /dev/null +++ b/public/profile.php @@ -0,0 +1,71 @@ +isLoggedIn()) { + http_response_code(403); + die('You must be logged in to view this page.'); +} + +$pUserName = (string)filter_input(INPUT_GET, 'name'); + +try { + $pUserInfo = SeriaUser::byName($pdo, $pUserName); +} catch(SeriaUserNotFoundException $ex) { + http_response_code(404); + die('User not found.'); +} + +$pTransferCount = $pUserInfo->getActiveTransferCounts(); +$pTransferRatio = $pUserInfo->calculateRatio(); + +$tPageTitle = $pUserInfo->getName(); + +require_once __DIR__ . '/_header.php'; + +echo '
'; + + +echo '
'; +echo '
'; +echo '
'; +echo '
' . $pUserInfo->getName() . '
'; +echo ''; +echo '
'; +echo '
'; + + +echo ''; + +$pSubmissions = $pUserInfo->getProfileSubmissions(); + +if(!empty($pSubmissions)) { + echo '
'; + echo '
Latest Submissions
'; + + foreach($pSubmissions as $submission) + echo $submission->toHTML($sUserInfo, 'profile-submission', false); + + echo ''; + echo '
'; +} + + +echo '
'; +echo '
Latest Transfers
'; + +echo 'todo: keep track of this'; + +echo ''; +echo '
'; + +echo '
'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..1f53798 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / diff --git a/public/seria.css b/public/seria.css new file mode 100644 index 0000000..c0dd1f9 --- /dev/null +++ b/public/seria.css @@ -0,0 +1,726 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + position: relative; + outline-style: none; +} + +html, +body, +.wrapper { + width: 100%; + height: 100%; +} + +.wrapper { + font: 12px/20px Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; + background: #111; + color: #fff; + padding: 0 5px; + display: flex; + flex-direction: column; +} + +.wrapper-body { + width: 100%; + max-width: 1200px; + margin: 0 auto; + flex: 1 0 auto; +} + +.avatar { + flex-shrink: 0; + background-color: #111; + display: block; + border: 0; + border-radius: 5%; + box-sizing: content-box; + vertical-align: middle; + max-width: 100%; + max-height: 100%; + overflow: hidden; +} +.avatar a { + display: block; + width: 100%; + height: 100%; +} +.avatar img { + vertical-align: top; +} + +.header { + background: url('//flashii.net/images/clouds.png') fixed #8559a5; + background-blend-mode: multiply; + overflow: hidden; + margin: 5px auto; + width: 100%; + max-width: 1200px; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + flex: 0 0 auto; +} +.header-logo { + font-size: 2.5em; + line-height: 1.1em; + padding: 15px 20px; +} +.header-logo a { + color: #fff; + text-decoration: none; +} +.header-menu { + display: flex; +} +.header-menu a { + display: block; + color: #fff; + text-decoration: none; + flex: 0 0 auto; + padding: 2px 10px; + margin: 5px 0; + margin-left: 5px; + border-radius: 2px; + text-align: center; + transition: background .2s; +} +.header-menu a:hover, +.header-menu a:focus { + background: rgba(255, 255, 255, .2); +} +.header-menu a:active { + background: rgba(255, 255, 255, .1); +} +.header-stats { + /*border: 1px solid #000; + background: linear-gradient(180deg, #545454 0%, #1a1a1a 50%, #000 50%) #545454;*/ + background-color: rgba(64, 64, 64, .5); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); + border-radius: 5px; + margin: 5px; + margin-bottom: 0; + user-select: none; + overflow: hidden; +} +.header-stats a { + color: #fff; + text-decoration: none; + padding: 2px 10px; + display: inline-block; + transition: background-color .1s; +} +.header-stats a:hover, +.header-stats a:focus { + background-color: rgba(255, 255, 255, .1); +} +.header-stats a:active { + background-color: rgba(0, 0, 0, .5); +} + +.footer { + background: #161616; + overflow: hidden; + margin: 5px auto; + width: 100%; + max-width: 1200px; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + padding: 5px; + flex: 0 0 auto; +} +.footer .copyright { + text-align: center; + font-size: .9em; + line-height: 1.4em; +} +.footer .disclaimer { + font-size: .8em; + line-height: 1.3em; + padding-top: 3px; +} +.footer a { + color: inherit; + text-decoration: none; +} +.footer a:hover, +.footer a:focus { + text-decoration: underline; +} + +.info { + /**/ +} + +.info-header { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + padding: 5px; +} +.info-header h2 { + margin: 5px; +} +.info-header ul { + list-style: none; + display: flex; +} +.info-header a { + color: #fff; + text-decoration: none; + text-transform: lowercase; + font-variant: small-caps; + display: inline-block; + padding: 2px 5px; +} +.info-header a:hover, +.info-header a:focus { + text-decoration: underline; +} +.info-header img { + vertical-align: middle; +} + +.info-stats { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + padding: 0 5px; + display: flex; + align-items: center; + margin-top: 2px; + flex-wrap: wrap; +} +.info-stats-item { + display: flex; + flex-direction: column; + flex: 0 0 auto; + margin-right: 10px; + border-bottom: 2px solid #fff; +} +.info-stats-item-title { + font-variant: small-caps; + padding: 5px; + padding-bottom: 0; +} +.info-stats-item-value { + font-size: 1.2em; + line-height: 1.4em; + padding: 5px; + padding-top: 0; +} +.info-stats-submitted { + border-color: #09f; +} +.info-stats-approved { + border-color: #0a0; +} +.info-stats-uploading { + border-color: green; +} +.info-stats-downloading { + border-color: red; +} +.info-stats-visibility { + border-color: orange; +} + +.info-user { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + padding: 0 5px; + display: flex; + align-items: center; + margin-top: 2px; +} +.info-user-uploaded { + margin-right: 4px; +} +.info-user-avatar { + margin-right: 4px; +} +.info-user-name {} +.info-user-name a { + color: #fff; + text-decoration: none; + border-bottom: 2px solid var(--user-colour, #fff); + border-top: 2px solid transparent; + font-weight: 700; + display: inline-block; +} + +.info-pending { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + display: flex; + margin-top: 2px; + height: 50px; +} +.info-pending-text { + flex: 1 1 auto; + padding: 0 20px; + font-size: 1.2em; + line-height: 48px; +} +.info-pending-approve { + flex: 0 0 auto; + display: block; + color: #fff; + text-decoration: none; + background: #2a2; + width: 100px; + text-align: center; + line-height: 49px; + transition: background .2s; +} +.info-pending-approve:hover, +.info-pending-approve:focus { + background: #2c2; +} +.info-pending-approve:active { + background: #282; +} +.info-pending-deny { + flex: 0 0 auto; + display: block; + color: #fff; + text-decoration: none; + background: #a22; + width: 100px; + text-align: center; + line-height: 49px; + transition: background .2s; +} +.info-pending-deny:hover, +.info-pending-deny:focus { + background: #c22; +} +.info-pending-deny:active { + background: #822; +} + +.info-comment { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; + margin-top: 2px; +} +.info-comment-header { + font-size: 1.4em; + padding: 5px 10px; +} +.info-comment-content { + padding: 5px 10px; + overflow: auto; +} +.info-comment-content pre { + line-height: 14px; +} + +.profile {} + +.profile-header { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + display: flex; + align-items: center; + padding: 2px; +} +.profile-header-info { + display: flex; + flex-direction: column; + padding: 0 10px; +} +.profile-header-info-name { + font-weight: 700; + font-size: 2em; + line-height: 1.5em; +} +.profile-header-info-name span { + border-bottom: 2px solid var(--user-colour, #fff); +} +.profile-header-info-flashii { + font-variant: small-caps; +} +.profile-header-info-flashii a { + color: #fff; + text-decoration: none; +} +.profile-header-info-flashii a:hover, +.profile-header-info-flashii a:focus { + text-decoration: underline; +} + +.profile-transfer { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + display: flex; + align-items: center; + padding: 2px; + margin-top: 2px; + font-size: 1.4em; +} +.profile-transfer a { + display: inline-block; + color: #fff; + text-decoration: none; + padding: 5px 10px; +} + +.profile-submissions { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; + margin-top: 2px; +} +.profile-submissions-header { + font-size: 1.4em; + padding: 5px 10px; +} +.profile-submissions-full { + width: 100%; +} +.profile-submissions-full a { + display: block; + width: 100%; + padding: 5px 10px; + text-align: center; + color: #fff; + text-decoration: none; + transition: background .2s; +} +.profile-submissions-full a:hover, +.profile-submissions-full a:focus { + background: rgba(255, 255, 255, .2); +} +.profile-submissions-full a:active { + background: rgba(255, 255, 255, .1); +} +.profile-submission { + margin: 0 2px; + margin-bottom: 2px; +} + + +.profile-history { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; + margin-top: 2px; +} +.profile-history-header { + font-size: 1.4em; + padding: 5px 10px; +} +.profile-history-full { + width: 100%; +} +.profile-history-full a { + display: block; + width: 100%; + padding: 5px 10px; + text-align: center; + color: #fff; + text-decoration: none; + transition: background .2s; +} +.profile-history-full a:hover, +.profile-history-full a:focus { + background: rgba(255, 255, 255, .2); +} +.profile-history-full a:active { + background: rgba(255, 255, 255, .1); +} + +.tdl { + display: flex; + border-radius: 2px; + background-color: rgba(17, 17, 17, .6); + transition: background-color .2s, box-shadow .2s; +} +.tdl:nth-child(even) { + background-color: rgba(25, 25, 25, .6); +} +.tdl:hover, +.tdl:focus { + background-color: rgba(34, 34, 34, .6); + box-shadow: 0 1px 4px #222; +} + +.tdl-details { + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + padding: 2px 4px; + margin: 0 2px; +} + +.tdl-details-name { + font-size: 1.4em; + line-height: 1.5em; +} +.tdl-details-name a { + color: #fff; + text-decoration: none; + padding: 2px; + display: inline-block; +} +.tdl-details-name a:hover, +.tdl-details-name a:active, +.tdl-details-name a:focus { + text-decoration: underline; +} + +.tdl-user { + display: flex; + align-items: center; + margin: 0 2px; +} +.tdl-user-avatar { + flex: 0 0 auto; + margin-right: 4px; +} +.tdl-user-name {} +.tdl-user-name a { + color: #fff; + text-decoration: none; + border-bottom: 2px solid var(--user-colour, #fff); + border-top: 2px solid transparent; + font-weight: 700; + display: inline-block; +} + +.tdl-stats { + flex: 0 0 auto; + display: flex; + font-size: 1.5em; + align-items: center; +} +.tdl-stats > div { + display: flex; + padding-left: 8px; + padding-bottom: 1px; +} +.tdl-stats .arrow { + margin-right: 2px; +} +.tdl-stats .arrow, +.tdl-stats .number { + flex: 0 0 auto; +} +.tdl-stats-uploading .arrow { + color: green; +} +.tdl-stats-downloading .arrow { + color: red; +} + +.tdl-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 1px; + margin-left: 10px; + max-width: 200px; + width: 100%; +} +.tdl-actions img { + vertical-align: top; +} +.tdl-actions a { + display: flex; + justify-content: center; + align-items: center; + margin: 1px; + padding: 8px; + border-radius: 2px; + color: #fff; + text-decoration: none; + background: rgba(255, 255, 255, .1); + transition: background .2s; +} +.tdl-actions a:hover, +.tdl-actions a:focus { + background: rgba(255, 255, 255, .3); +} +.tdl-actions a:active { + background: rgba(255, 255, 255, .2); +} + +.downloads { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; +} +.downloads-header { + font-size: 1.4em; + padding: 5px 10px; +} +.downloads-more { + width: 100%; +} +.downloads-more a { + display: block; + width: 100%; + padding: 5px 10px; + text-align: center; + color: #fff; + text-decoration: none; + transition: background .2s; +} +.downloads-more a:hover, +.downloads-more a:focus { + background: rgba(255, 255, 255, .2); +} +.downloads-more a:active { + background: rgba(255, 255, 255, .1); +} +.downloads-item { + margin: 0 2px; + margin-bottom: 2px; +} +.downloads-nothing { + text-align: center; + font-size: 1.2em; + padding: 10px; +} + +.index { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; +} +.index h2 { + font-size: 1.4em; + font-weight: 400; + padding: 5px 10px; +} +.index p { + margin: .2em 10px; +} + +.create {} + +.create-header { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + padding: 2px; +} +.create-header h2 { + font-size: 1.4em; + font-weight: 400; + padding: 5px 10px; +} +.create-header p { + margin: .2em 10px; +} +.create-header code { + display: inline-block; +} + +.create-form { + background: #161616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + margin-top: 2px; +} +.create-form form { + display: flex; +} +.create-form-file { + flex: 1 1 auto; + display: flex; + align-items: center; + padding: 0 14px; +} +.create-form-submit { + flex: 0 0 auto; + background: #333; + color: #fff; + border: 0; + font-family: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif; + font-size: 16px; + line-height: 32px; + padding: 10px; + transition: background .2s; +} +.create-form-submit:hover, +.create-form-submit:focus { + background: #3A3A3A; +} +.create-form-submit:active { + background: #303030; +} + +.create-error { + background: #361616; + overflow: hidden; + width: 100%; + box-shadow: 0 1px 2px rgba(0, 0, 0, .6); + text-shadow: 0 1px 4px #000; + align-items: center; + margin-top: 2px; + display: flex; +} +.create-error-icon { + flex: 0 0 auto; + background: #561616; + width: 40px; + height: 40px; + text-align: center; + line-height: 38px; +} +.create-error-icon img { + vertical-align: middle; +} +.create-error-text { + flex: 1 1 auto; + font-size: 1.2em; + padding: 4px 10px; +} diff --git a/public/settings.php b/public/settings.php new file mode 100644 index 0000000..95b96ff --- /dev/null +++ b/public/settings.php @@ -0,0 +1,15 @@ +isLoggedIn()) { + http_response_code(404); + die('You must be logged in to view this page.'); +} + +$tPageTitle = 'Settings'; + +require_once __DIR__ . '/_header.php'; + +echo 'Provide option to reset pass key and shit here, maybe also a nuke tracker profile option but probably not.'; + +require_once __DIR__ . '/_footer.php'; diff --git a/public/test.php b/public/test.php new file mode 100644 index 0000000..69e3cea --- /dev/null +++ b/public/test.php @@ -0,0 +1,41 @@ + +> 16) & 0xFF, ($colour1 >> 8) & 0xFF, $colour1 & 0xFF ]; + $colour2 = [ ($colour2 >> 16) & 0xFF, ($colour2 >> 8) & 0xFF, $colour2 & 0xFF ]; + return (seria_weighted_number($colour1[0], $colour2[0], $weight) << 16) + | (seria_weighted_number($colour1[1], $colour2[1], $weight) << 8) + | seria_weighted_number($colour1[2], $colour2[2], $weight); +} + +function seria_weighted_colour_hex(int $colour1, int $colour2, float $weight): string { + return sprintf('#%06x', seria_weighted_colour($colour1, $colour2, $weight)); +} + +function seria_easeInQuad(float $n): float { + return $n * $n; +} + +function seria_easeOutQuad(float $n): float { + return 1 - (1 - $n) * (1 - $n); +} + +function seria_ratio_colour(float $ratio): string { + $ratio *= 2; + if($ratio > 1) + return seria_weighted_colour_hex(0x008000, 0xFFAA00, seria_easeInQuad($ratio - 1)); + return seria_weighted_colour_hex(0xFFAA00, 0xFF0000, seria_easeOutQuad($ratio)); +} + +for($i = 0; $i <= 100; ++$i) { + $if = $i / 100; + printf('%1$01.2f %2$s
', $if, seria_ratio_colour($if)); +} diff --git a/seria.php b/seria.php new file mode 100644 index 0000000..8e33b50 --- /dev/null +++ b/seria.php @@ -0,0 +1,164 @@ + PDO::CASE_NATURAL, + PDO::ATTR_ERRMODE => PDO::ERRMODE_WARNING, + PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, + PDO::ATTR_STRINGIFY_FETCHES => false, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION time_zone = \'+00:00\'' + . ', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'', + ]); +} catch(PDOException $ex) { + die($ex->getMessage()); +} + +require_once SERIA_SRC . '/user.php'; +require_once SERIA_SRC . '/benben.php'; +require_once SERIA_SRC . '/torrent.php'; +require_once SERIA_SRC . '/announce.php'; + +header('X-Powered-By: Seria/' . SERIA_VERSION); + +$sInAnnounce = $_SERVER['SCRIPT_NAME'] === '/announce.php'; +$sIsIPv4 = strlen(inet_pton($_SERVER['REMOTE_ADDR'])) === 4; + +if(!$sInAnnounce && !$sIsIPv4) + die('The tracker is only supported over IPv4, please reset your DNS cache.'); + +$sUserInfo = SeriaUser::anonymous(); +$sVerification = ''; + +if(!$sInAnnounce) { + // replace this with id.flashii.net shit + $mszAuth = (string)filter_input(INPUT_COOKIE, 'msz_auth'); + if(!empty($mszAuth)) { + $mszAuthDecoded = str_pad(base64_decode(str_pad(strtr($mszAuth, '-_', '+/'), strlen($mszAuth) % 4, '=', STR_PAD_RIGHT)), 37, "\0"); + $mszAuthUnpacked = unpack('Cversion/Nuser/H*token', $mszAuthDecoded); + + if(isset($mszAuthUnpacked['version']) + && $mszAuthUnpacked['version'] >= 1 + && isset($mszAuthUnpacked['user']) + && $mszAuthUnpacked['user'] > 0) { + $loginRequest = [ + 'user_id' => $mszAuthUnpacked['user'], + 'token' => 'SESS:' . $mszAuth, + 'ip' => $_SERVER['REMOTE_ADDR'], + ]; + $loginSignature = hash_hmac('sha256', implode('#', $loginRequest), SERIA_MSZ_SECRET); + + $login = curl_init(SERIA_CAUTH_ENDPOINT); + curl_setopt_array($login, [ + CURLOPT_AUTOREFERER => false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HEADER => false, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => json_encode($loginRequest), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_MAXREDIRS => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Seria/' . SERIA_VERSION, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-SharpChat-Signature: ' . $loginSignature, + ], + ]); + $loginResponse = json_decode(curl_exec($login)); + curl_close($login); + + if(!empty($loginResponse->success)) + $sUserInfo = SeriaUser::fromMisuzu($pdo, $loginResponse); + + unset($mszAuth, $mszAuthDecoded, $mszAuthUnpacked, $loginRequest, $loginSignature, $login, $loginResponse); + } + } + + if(empty($_COOKIE['seria_random'])) { + $sVerification = SeriaUser::generatePassKey(32); + setcookie('seria_random', $sVerification, strtotime('1 day'), '/', $_SERVER['HTTP_HOST']); + } else + $sVerification = (string)filter_input(INPUT_COOKIE, 'seria_random'); + + $sVerification = hash('sha256', $sVerification); +} + +function byte_symbol(int $bytes, bool $decimal = true, array $symbols = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']): string { + if($bytes < 1) + return '0 B'; + + $divider = $decimal ? 1000 : 1024; + $exp = floor(log($bytes) / log($divider)); + $bytes = $bytes / pow($divider, $exp); + $symbol = $symbols[$exp]; + + return sprintf("%.2f %s%sB", $bytes, $symbol, $symbol !== '' && !$decimal ? 'i' : ''); +} + +function seria_weighted_number(float $num1, float $num2, float $weight): int { + $weight = min(1, max(0, $weight)); + return round(($num1 * $weight) + ($num2 * (1 - $weight))); +} + +function seria_weighted_colour(int $colour1, int $colour2, float $weight): int { + $colour1 = [ ($colour1 >> 16) & 0xFF, ($colour1 >> 8) & 0xFF, $colour1 & 0xFF ]; + $colour2 = [ ($colour2 >> 16) & 0xFF, ($colour2 >> 8) & 0xFF, $colour2 & 0xFF ]; + return (seria_weighted_number($colour1[0], $colour2[0], $weight) << 16) + | (seria_weighted_number($colour1[1], $colour2[1], $weight) << 8) + | seria_weighted_number($colour1[2], $colour2[2], $weight); +} + +function seria_weighted_colour_hex(int $colour1, int $colour2, float $weight): string { + return sprintf('#%06x', seria_weighted_colour($colour1, $colour2, $weight)); +} + +function seria_easeInQuad(float $n): float { + return $n * $n; +} + +function seria_easeOutQuad(float $n): float { + return 1 - (1 - $n) * (1 - $n); +} + +function seria_ratio_colour(float $ratio): string { + $ratio *= 2; + if($ratio > 1) + return seria_weighted_colour_hex(0x008000, 0xFFAA00, seria_easeInQuad($ratio - 1)); + return seria_weighted_colour_hex(0xFFAA00, 0xFF0000, seria_easeOutQuad($ratio)); +} + +function seria_size_colour(int $bytes): string { + if($bytes >= 53687090000) + return '#ec32a4'; + if($bytes >= 21474840000) + return '#db5ff1'; + if($bytes >= 10737420000) + return '#cca1f4'; + if($bytes >= 5368709000) + return '#cdd'; + if($bytes >= 1073742000) + return '#bae9c7'; + return '#a0f5b8'; +} diff --git a/src/announce.php b/src/announce.php new file mode 100644 index 0000000..286ffd4 --- /dev/null +++ b/src/announce.php @@ -0,0 +1,51 @@ +interval = $interval; + $this->minInterval = $minInterval; + $this->torrent = $torrent; + $this->includePeerId = $includePeerId; + $this->compactPeers = $compactPeers; + } + + public function addPeer(SeriaTorrentPeer $peer): void { + if(!in_array($peer, $this->peers)) + $this->peers[] = $peer; + } + + public function bencodeSerialize(): mixed { + $data = [ + 'interval' => $this->interval, + 'min interval' => $this->minInterval, + 'complete' => $this->torrent?->getCompletePeers() ?? 0, + 'incomplete' => $this->torrent?->getIncompletePeers() ?? 0, + ]; + + if($this->compactPeers) { + $peers = ''; + foreach($this->peers as $peer) + $peers .= $peer->getAddressRaw() . pack('n', $peer->getPort()); + $data['peers'] = $peers; + } else { + $peers = []; + foreach($this->peers as $peer) + $peers[] = $peer->encodeInfo($this->includePeerId); + $data['peers'] = $peers; + } + + return $data; + } +} diff --git a/src/benben.php b/src/benben.php new file mode 100644 index 0000000..2bbf12e --- /dev/null +++ b/src/benben.php @@ -0,0 +1,146 @@ + $value) { + $output .= bencode(strval($key)); + $output .= bencode($value); + } + } + + return $output . 'e'; + + case 'object': + if($input instanceof BEncodeSerializable) + return bencode($input->bencodeSerialize()); + + $input = get_object_vars($input); + $output = 'd'; + + foreach($input as $key => $value) { + $output .= bencode(strval($key)); + $output .= bencode($value); + } + + return $output . 'e'; + + default: + return ''; + } +} diff --git a/src/torrent.php b/src/torrent.php new file mode 100644 index 0000000..57fd23d --- /dev/null +++ b/src/torrent.php @@ -0,0 +1,877 @@ +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 = '
'; + + $html .= '
'; + + $html .= ''; + + if($showSubmitter) { + try { + $submitter = SeriaUser::byId($this->pdo, $this->getUserId()); + + $html .= '
'; + $html .= '
'; + $html .= ''; + $html .= '
'; + } catch(SeriaUserNotFoundException $ex) {} + } + + $html .= '
'; + + + $html .= '
'; + $html .= '
' . number_format($this->getCompletePeers()) . '
'; + $html .= '
' . number_format($this->getIncompletePeers()) . '
'; + $html .= '
'; + + $html .= '
'; + + if(!$this->isApproved() && $user->canApproveTorrents() && $verification !== null) { + $html .= 'Approve'; + $html .= 'Deny'; + } + + if($this->canDownload($user) === '') { + $html .= 'Download'; + } + + $html .= '
'; + + $html .= '
'; + + 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()'); + } +} diff --git a/src/user.php b/src/user.php new file mode 100644 index 0000000..a08e367 --- /dev/null +++ b/src/user.php @@ -0,0 +1,217 @@ +raw = $raw; + } + + public function __toString(): string { + if($this->raw & self::INHERIT) + return 'inherit'; + return '#' . str_pad(dechex($this->raw & 0xFFFFFF), 6, '0', STR_PAD_LEFT); + } +} + +class SeriaUser { + private const KEY_CHARS = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz0123456789'; + private const KEY_LENGTH = 48; + + private ?PDO $pdo; + + private string $user_id = ''; + private string $user_name = 'Anonymous'; + private int $user_colour = SeriaUserColour::INHERIT; + private int $user_rank = 0; + private int $user_permissions = 0; + private ?string $user_pass_key = null; + private int $user_bytes_downloaded = 0; + private int $user_bytes_uploaded = 0; + + public function __construct(?PDO $pdo = null) { + $this->pdo = $pdo; + } + + public function getId(): string { + return $this->user_id; + } + + public function getName(): string { + return $this->user_name; + } + + public function getColour(): SeriaUserColour { + return new SeriaUserColour($this->user_colour); + } + + public function getColourRaw(): int { + return $this->user_colour; + } + + public function getRank(): int { + return $this->user_rank; + } + + public function getPermissionsRaw(): int { + return $this->user_permissions; + } + + public function getPassKey(): string { + if(!$this->isLoggedIn()) + return ''; + + if(($passKey = $this->user_pass_key) === null) + return $this->resetPassKey(); + + return $passKey; + } + + public function getBytesDownloaded(): int { + return $this->user_bytes_downloaded; + } + + public function getBytesUploaded(): int { + return $this->user_bytes_uploaded; + } + + public function isLoggedIn() { + return !empty($this->user_id); + } + + public function isFlash(): bool { + return $this->user_id === '1'; + } + + public function canCreateTorrents(): bool { + return $this->isFlash() + || $this->user_id === '32' + || $this->user_id === '145'; + } + + public function canApproveTorrents(): bool { + return $this->isFlash(); + } + + public function canRecalculateInfoHash(): bool { + return $this->isFlash(); + } + + public function calculateRatio(): float { + $bd = $this->getBytesDownloaded(); + if($bd === 0) + return 0; + return $this->getBytesUploaded() / $bd; + } + + public function getActiveTransferCounts(): stdClass { + return SeriaTorrentPeer::countUserStats($this->pdo, $this); + } + + public function getProfileSubmissions(): array { + return $this->getSubmissions(-1, 3); + } + + public function getSubmissions(int $startAt, int $take): array { + return SeriaTorrent::byUser($this->pdo, $this, $startAt, $take); + } + + public static function generatePassKey(int $length): string { + $keyChars = strlen(self::KEY_CHARS) - 1; + $bytes = str_repeat("\0", $length); + for($i = 0; $i < $length; ++$i) + $bytes[$i] = self::KEY_CHARS[random_int(0, $keyChars)]; + return $bytes; + } + + public function resetPassKey(): string { + if(!$this->isLoggedIn()) + return ''; + + $updatePassKey = $this->pdo->prepare('UPDATE `ser_users` SET `user_pass_key` = :pass WHERE `user_id` = :user'); + $updatePassKey->bindValue('user', $this->user_id); + + do { + $updatePassKey->bindValue('pass', $passKey = self::generatePassKey(self::KEY_LENGTH)); + } while(!$updatePassKey->execute()); + + return $this->user_pass_key = $passKey; + } + + public function incrementTransferCounts(int $bytesDownloaded, int $bytesUploaded): void { + if(!$this->isLoggedIn()) + return; + + $updateStats = $this->pdo->prepare('UPDATE `ser_users` SET `user_bytes_downloaded` = `user_bytes_downloaded` + :downloaded, `user_bytes_uploaded` = `user_bytes_uploaded` + :uploaded WHERE `user_id` = :user'); + $updateStats->bindValue('downloaded', $bytesDownloaded); + $updateStats->bindValue('uploaded', $bytesUploaded); + $updateStats->bindValue('user', $this->user_id); + + if($updateStats->execute()) { + $this->user_bytes_downloaded += $bytesDownloaded; + $this->user_bytes_uploaded += $bytesUploaded; + } + } + + public static function anonymous(): self { + return new static; + } + + public static function fromMisuzu(PDO $pdo, stdClass $info): self { + $update = $pdo->prepare('INSERT INTO `ser_users` (`user_id`, `user_name`, `user_colour`, `user_rank`, `user_permissions`) VALUES (:id, :name_1, :colour_1, :rank_1, :perms_1) ON DUPLICATE KEY UPDATE `user_name` = :name_2, `user_colour` = :colour_2, `user_rank` = :rank_2, `user_permissions` = :perms_2'); + $update->bindValue('id', $info->user_id); + $update->bindValue('name_1', $info->username); + $update->bindValue('name_2', $info->username); + $update->bindValue('colour_1', $info->colour_raw); + $update->bindValue('colour_2', $info->colour_raw); + $update->bindValue('rank_1', $info->hierarchy); + $update->bindValue('rank_2', $info->hierarchy); + $update->bindValue('perms_1', $info->perms); + $update->bindValue('perms_2', $info->perms); + $update->execute(); + return self::byId($pdo, $info->user_id); + } + + public static function byId(PDO $pdo, string $userId): self { + $getUser = $pdo->prepare('SELECT `user_id`, `user_name`, `user_colour`, `user_rank`, `user_permissions`, `user_pass_key`, `user_bytes_downloaded`, `user_bytes_uploaded` FROM `ser_users` WHERE `user_id` = :user'); + $getUser->bindValue('user', $userId); + + if(!$getUser->execute()) + throw new SeriaUserExtremelyNotFoundException; + + if(($obj = $getUser->fetchObject(self::class, [$pdo])) === false) + throw new SeriaUserNotFoundException; + + return $obj; + } + + public static function byName(PDO $pdo, string $userName): self { + $getUser = $pdo->prepare('SELECT `user_id`, `user_name`, `user_colour`, `user_rank`, `user_permissions`, `user_pass_key`, `user_bytes_downloaded`, `user_bytes_uploaded` FROM `ser_users` WHERE `user_name` = :user'); + $getUser->bindValue('user', $userName); + + if(!$getUser->execute()) + throw new SeriaUserExtremelyNotFoundException; + + if(($obj = $getUser->fetchObject(self::class, [$pdo])) === false) + throw new SeriaUserNotFoundException; + + return $obj; + } + + public static function byPassKey(PDO $pdo, string $passKey): self { + $getUser = $pdo->prepare('SELECT `user_id`, `user_name`, `user_colour`, `user_rank`, `user_permissions`, `user_pass_key`, `user_bytes_downloaded`, `user_bytes_uploaded` FROM `ser_users` WHERE `user_pass_key` = :pass'); + $getUser->bindValue('pass', $passKey); + + if(!$getUser->execute()) + throw new SeriaUserExtremelyNotFoundException; + + if(($obj = $getUser->fetchObject(self::class, [$pdo])) === false) + throw new SeriaUserNotFoundException; + + return $obj; + } +}