From bd17a0feb53e8e4dc60222fb3fb672821764a3ca Mon Sep 17 00:00:00 2001 From: flashwave Date: Fri, 6 Oct 2023 21:11:28 +0000 Subject: [PATCH] Rewrote the Tracker backend on top of Index. --- .gitignore | 1 + composer.json | 25 + composer.lock | 920 ++++++++++++++++++ .../2023_09_15_233145_existing_structure.php | 140 +++ phpstan.neon | 6 + public/_footer.php | 18 - public/_header.php | 52 - public/announce.php | 144 --- public/{ => assets}/seria.css | 27 + public/assets/seria.js | 157 +++ public/available.php | 62 -- public/create.php | 88 -- public/download.php | 37 - public/history.php | 27 - public/index.php | 28 +- public/info.php | 154 --- public/pending.php | 55 -- public/profile.php | 71 -- public/settings.php | 15 - public/test.php | 41 - seria.php | 164 +--- src/Auth/AuthInfo.php | 43 + src/Auth/ChatAuth.php | 54 + src/Colours.php | 60 ++ src/GitInfo.php | 24 + src/HomeRoutes.php | 22 + src/RoutingContext.php | 49 + src/SeriaContext.php | 117 +++ src/SeriaSasaeExtension.php | 71 ++ src/SiteInfo.php | 34 + src/Torrents/AnnounceEmpty.php | 20 + src/Torrents/AnnounceFailure.php | 12 + src/Torrents/AnnounceInfo.php | 77 ++ src/Torrents/AnnounceRouting.php | 144 +++ src/Torrents/TorrentBuilder.php | 205 ++++ src/Torrents/TorrentCreateRouting.php | 102 ++ src/Torrents/TorrentFileInfo.php | 42 + src/Torrents/TorrentFiles.php | 49 + src/Torrents/TorrentInfo.php | 82 ++ src/Torrents/TorrentInfoRouting.php | 184 ++++ src/Torrents/TorrentListRouting.php | 136 +++ src/Torrents/TorrentPeerInfo.php | 108 ++ src/Torrents/TorrentPeers.php | 142 +++ src/Torrents/TorrentPieceInfo.php | 28 + src/Torrents/TorrentPieces.php | 39 + src/Torrents/Torrents.php | 161 +++ src/Torrents/TorrentsContext.php | 87 ++ src/Users/ProfileRoutes.php | 87 ++ src/Users/SettingsRoutes.php | 27 + src/Users/UserInfo.php | 101 ++ src/Users/Users.php | 127 +++ src/Users/UsersContext.php | 16 + src/announce.php | 51 - src/benben.php | 146 --- src/torrent.php | 877 ----------------- src/user.php | 217 ----- templates/available.twig | 31 + templates/create.twig | 33 + templates/history.twig | 8 + templates/http-error-500.html | 16 + templates/http-error.twig | 12 + templates/index.twig | 10 + templates/info.twig | 112 +++ templates/macros.twig | 34 + templates/master.twig | 59 ++ templates/pending.twig | 57 ++ templates/profile.twig | 48 + templates/settings.twig | 7 + tools/migrate | 34 + tools/new-migration | 25 + 70 files changed, 4248 insertions(+), 2211 deletions(-) create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 database/2023_09_15_233145_existing_structure.php create mode 100644 phpstan.neon delete mode 100644 public/_footer.php delete mode 100644 public/_header.php delete mode 100644 public/announce.php rename public/{ => assets}/seria.css (96%) create mode 100644 public/assets/seria.js delete mode 100644 public/available.php delete mode 100644 public/create.php delete mode 100644 public/download.php delete mode 100644 public/history.php delete mode 100644 public/info.php delete mode 100644 public/pending.php delete mode 100644 public/profile.php delete mode 100644 public/settings.php delete mode 100644 public/test.php create mode 100644 src/Auth/AuthInfo.php create mode 100644 src/Auth/ChatAuth.php create mode 100644 src/Colours.php create mode 100644 src/GitInfo.php create mode 100644 src/HomeRoutes.php create mode 100644 src/RoutingContext.php create mode 100644 src/SeriaContext.php create mode 100644 src/SeriaSasaeExtension.php create mode 100644 src/SiteInfo.php create mode 100644 src/Torrents/AnnounceEmpty.php create mode 100644 src/Torrents/AnnounceFailure.php create mode 100644 src/Torrents/AnnounceInfo.php create mode 100644 src/Torrents/AnnounceRouting.php create mode 100644 src/Torrents/TorrentBuilder.php create mode 100644 src/Torrents/TorrentCreateRouting.php create mode 100644 src/Torrents/TorrentFileInfo.php create mode 100644 src/Torrents/TorrentFiles.php create mode 100644 src/Torrents/TorrentInfo.php create mode 100644 src/Torrents/TorrentInfoRouting.php create mode 100644 src/Torrents/TorrentListRouting.php create mode 100644 src/Torrents/TorrentPeerInfo.php create mode 100644 src/Torrents/TorrentPeers.php create mode 100644 src/Torrents/TorrentPieceInfo.php create mode 100644 src/Torrents/TorrentPieces.php create mode 100644 src/Torrents/Torrents.php create mode 100644 src/Torrents/TorrentsContext.php create mode 100644 src/Users/ProfileRoutes.php create mode 100644 src/Users/SettingsRoutes.php create mode 100644 src/Users/UserInfo.php create mode 100644 src/Users/Users.php create mode 100644 src/Users/UsersContext.php delete mode 100644 src/announce.php delete mode 100644 src/benben.php delete mode 100644 src/torrent.php delete mode 100644 src/user.php create mode 100644 templates/available.twig create mode 100644 templates/create.twig create mode 100644 templates/history.twig create mode 100644 templates/http-error-500.html create mode 100644 templates/http-error.twig create mode 100644 templates/index.twig create mode 100644 templates/info.twig create mode 100644 templates/macros.twig create mode 100644 templates/master.twig create mode 100644 templates/pending.twig create mode 100644 templates/profile.twig create mode 100644 templates/settings.twig create mode 100755 tools/migrate create mode 100755 tools/new-migration diff --git a/.gitignore b/.gitignore index bdb37c9..fde1f90 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ /.debug /config.php /errors.log +/vendor diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..3f37d34 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "flashwave/index": "dev-master", + "flashwave/sasae": "dev-master", + "erusev/parsedown": "~1.6" + }, + "autoload": { + "classmap": [ + "database" + ], + "psr-4": { + "Seria\\": "src" + } + }, + "scripts": { + "post-install-cmd": [ + "./tools/migrate" + ] + }, + "require-dev": { + "phpstan/phpstan": "^1.10" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..4fa7a4f --- /dev/null +++ b/composer.lock @@ -0,0 +1,920 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "6c0d442a01899edbfc60a28c35e7f945", + "packages": [ + { + "name": "erusev/parsedown", + "version": "1.7.4", + "source": { + "type": "git", + "url": "https://github.com/erusev/parsedown.git", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/erusev/parsedown/zipball/cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "reference": "cb17b6477dfff935958ba01325f2e8a2bfa6dab3", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35" + }, + "type": "library", + "autoload": { + "psr-0": { + "Parsedown": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Emanuil Rusev", + "email": "hello@erusev.com", + "homepage": "http://erusev.com" + } + ], + "description": "Parser for Markdown.", + "homepage": "http://parsedown.org", + "keywords": [ + "markdown", + "parser" + ], + "support": { + "issues": "https://github.com/erusev/parsedown/issues", + "source": "https://github.com/erusev/parsedown/tree/1.7.x" + }, + "time": "2019-12-30T22:54:17+00:00" + }, + { + "name": "flashwave/index", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://git.flash.moe/flash/index.git", + "reference": "82a350a5c719cc83aa22382201683a68a2629f2a" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.2" + }, + "suggest": { + "ext-mysqli": "Support for the Index\\Data\\MariaDB namespace (both mysqlnd and libmysql are supported).", + "ext-sqlite3": "Support for the Index\\Data\\SQLite namespace." + }, + "default-branch": true, + "type": "library", + "autoload": { + "files": [ + "index.php" + ], + "psr-4": { + "Index\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "bsd-3-clause-clear" + ], + "authors": [ + { + "name": "flashwave", + "email": "packagist@flash.moe", + "homepage": "https://flash.moe", + "role": "mom" + } + ], + "description": "Composer package for the common library for my projects.", + "homepage": "https://railgun.sh/index", + "time": "2023-09-15T22:44:36+00:00" + }, + { + "name": "flashwave/sasae", + "version": "dev-master", + "source": { + "type": "git", + "url": "https://git.flash.moe/flash/sasae.git", + "reference": "739669fc8ce7ea862ed2129cb24976ceebd0f4c7" + }, + "require": { + "flashwave/index": "dev-master", + "php": ">=8.2", + "twig/html-extra": "^3.7", + "twig/twig": "^3.7" + }, + "require-dev": { + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.2" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Sasae\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "bsd-3-clause-clear" + ], + "authors": [ + { + "name": "flashwave", + "email": "packagist@flash.moe", + "homepage": "https://flash.moe", + "role": "mom" + } + ], + "description": "A wrapper for Twig with added common functionality.", + "homepage": "https://railgun.sh/sasae", + "time": "2023-08-24T23:24:45+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/7c3aff79d10325257a001fcf92d991f24fc967cf", + "reference": "7c3aff79d10325257a001fcf92d991f24fc967cf", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.4-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-05-23T14:45:45+00:00" + }, + { + "name": "symfony/mime", + "version": "v6.3.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/d5179eedf1cb2946dbd760475ebf05c251ef6a6e", + "reference": "d5179eedf1cb2946dbd760475ebf05c251ef6a6e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<5.4", + "symfony/serializer": "<6.2.13|>=6.3,<6.3.2" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", + "symfony/property-info": "^5.4|^6.0", + "symfony/serializer": "~6.2.13|^6.3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v6.3.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-09-29T06:59:36+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/ecaafce9f77234a6a449d29e49267ba10499116d", + "reference": "ecaafce9f77234a6a449d29e49267ba10499116d", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:30:37+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "42292d99c55abe617799667f454222c54c60e229" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", + "reference": "42292d99c55abe617799667f454222c54c60e229", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-07-28T09:04:16+00:00" + }, + { + "name": "symfony/polyfill-php72", + "version": "v1.28.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php72.git", + "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/70f4aebd92afca2f865444d30a4d2151c13c3179", + "reference": "70f4aebd92afca2f865444d30a4d2151c13c3179", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.28-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php72/tree/v1.28.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-01-26T09:26:14+00:00" + }, + { + "name": "twig/html-extra", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/html-extra.git", + "reference": "95ceb36e70fa8d07af08cf5135ecbf5e0bd8f386" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/html-extra/zipball/95ceb36e70fa8d07af08cf5135ecbf5e0bd8f386", + "reference": "95ceb36e70fa8d07af08cf5135ecbf5e0bd8f386", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/mime": "^5.4|^6.0", + "twig/twig": "^2.7|^3.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^5.4|^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\Extra\\Html\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + } + ], + "description": "A Twig extension for HTML", + "homepage": "https://twig.symfony.com", + "keywords": [ + "html", + "twig" + ], + "support": { + "source": "https://github.com/twigphp/html-extra/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-07-29T15:34:56+00:00" + }, + { + "name": "twig/twig", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", + "reference": "a0ce373a0ca3bf6c64b9e3e2124aca502ba39554", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2023-08-28T11:09:02+00:00" + } + ], + "packages-dev": [ + { + "name": "phpstan/phpstan", + "version": "1.10.38", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "5302bb402c57f00fb3c2c015bac86e0827e4b691" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/5302bb402c57f00fb3c2c015bac86e0827e4b691", + "reference": "5302bb402c57f00fb3c2c015bac86e0827e4b691", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan", + "type": "tidelift" + } + ], + "time": "2023-10-06T14:19:14+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": { + "flashwave/index": 20, + "flashwave/sasae": 20 + }, + "prefer-stable": true, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/database/2023_09_15_233145_existing_structure.php b/database/2023_09_15_233145_existing_structure.php new file mode 100644 index 0000000..f724ab9 --- /dev/null +++ b/database/2023_09_15_233145_existing_structure.php @@ -0,0 +1,140 @@ +query('SHOW TABLES'); + while($tables->next()) { + $tableName = $tables->getString(0); + if($tableName === 'ser_torrents') + $hasTorrents = true; + elseif($tableName === 'ser_torrents_files') + $hasTorrentsFiles = true; + elseif($tableName === 'ser_torrents_peers') + $hasTorrentsPeers = true; + elseif($tableName === 'ser_torrents_pieces') + $hasTorrentsPieces = true; + elseif($tableName === 'ser_users') + $hasUsers = true; + } + + if(!$hasUsers) + $conn->execute(' + CREATE TABLE ser_users ( + user_id INT(10) UNSIGNED NOT NULL, + user_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci", + user_colour INT(10) UNSIGNED NOT NULL, + user_rank INT(11) NOT NULL, + user_permissions INT(10) UNSIGNED NOT NULL, + user_pass_key BINARY(48) NULL DEFAULT NULL, + user_bytes_downloaded BIGINT(20) UNSIGNED NOT NULL DEFAULT "0", + user_bytes_uploaded BIGINT(20) UNSIGNED NOT NULL DEFAULT "1000000", + PRIMARY KEY (user_id), + UNIQUE KEY ser_users_name_unique (user_name), + UNIQUE KEY ser_users_pass_key_unique (user_pass_key) + ) ENGINE=InnoDB COLLATE="utf8mb4_bin"; + '); + + if(!$hasTorrents) + $conn->execute(' + CREATE TABLE ser_torrents ( + torrent_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + user_id INT(10) UNSIGNED NULL DEFAULT NULL, + torrent_hash BINARY(20) NOT NULL, + torrent_active TINYINT(4) UNSIGNED NOT NULL DEFAULT "1", + torrent_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_bin", + torrent_created TIMESTAMP NOT NULL DEFAULT current_timestamp(), + torrent_approved TIMESTAMP NULL DEFAULT NULL, + torrent_piece_length INT(10) UNSIGNED NOT NULL DEFAULT "0", + torrent_private TINYINT(3) UNSIGNED NOT NULL DEFAULT "0", + torrent_comment TEXT NOT NULL DEFAULT "" COLLATE "utf8mb4_bin", + PRIMARY KEY (torrent_id), + UNIQUE KEY ser_torrents_hash_unique (torrent_hash), + KEY ser_torrents_active_index (torrent_active), + KEY ser_torrents_user_foreign (user_id), + KEY ser_torrents_approved_index (torrent_approved), + KEY ser_torrents_private_index (torrent_private), + CONSTRAINT ser_torrents_user_foreign + FOREIGN KEY (user_id) + REFERENCES ser_users (user_id) + ON UPDATE CASCADE + ON DELETE SET NULL + ) ENGINE=InnoDB COLLATE="utf8mb4_bin"; + '); + + if(!$hasTorrentsFiles) + $conn->execute(' + CREATE TABLE ser_torrents_files ( + file_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + torrent_id INT(10) UNSIGNED NOT NULL, + file_length BIGINT(20) UNSIGNED NOT NULL, + file_path VARCHAR(255) NOT NULL COLLATE "utf8mb4_bin", + PRIMARY KEY (file_id), + UNIQUE KEY ser_torrents_files_path_unique (file_id, file_path), + KEY ser_torrents_files_torrent_foreign (torrent_id), + CONSTRAINT ser_torrents_files_torrent_foreign + FOREIGN KEY (torrent_id) + REFERENCES ser_torrents (torrent_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE="utf8mb4_bin"; + '); + + if(!$hasTorrentsPieces) + $conn->execute(' + CREATE TABLE ser_torrents_pieces ( + piece_id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + torrent_id INT(10) UNSIGNED NOT NULL, + piece_hash BINARY(20) NOT NULL, + PRIMARY KEY (piece_id), + KEY ser_torrents_pieces_torrent_foreign (torrent_id), + CONSTRAINT ser_torrents_pieces_torrent_foreign + FOREIGN KEY (torrent_id) + REFERENCES ser_torrents (torrent_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE="utf8mb4_bin"; + '); + + if(!$hasTorrentsPeers) + $conn->execute(' + CREATE TABLE ser_torrents_peers ( + peer_id BINARY(20) NOT NULL, + torrent_id INT(10) UNSIGNED NOT NULL, + user_id INT(10) UNSIGNED NOT NULL, + peer_address BINARY(4) NOT NULL, + peer_port SMALLINT(5) UNSIGNED NOT NULL, + peer_updated TIMESTAMP NOT NULL DEFAULT current_timestamp(), + peer_expires TIMESTAMP NOT NULL DEFAULT from_unixtime(0), + peer_agent VARCHAR(255) NOT NULL DEFAULT "" COLLATE "utf8mb4_bin", + peer_key VARBINARY(64) NOT NULL DEFAULT "", + peer_uploaded BIGINT(20) UNSIGNED NOT NULL, + peer_downloaded BIGINT(20) UNSIGNED NOT NULL, + peer_left BIGINT(20) UNSIGNED NOT NULL, + PRIMARY KEY (peer_id, torrent_id), + KEY ser_peers_expires_index (peer_expires), + KEY ser_peers_torrent_foreign (torrent_id), + KEY ser_peers_left_index (peer_left), + KEY ser_peers_user_foreign (user_id), + CONSTRAINT ser_peers_torrent_foreign + FOREIGN KEY (torrent_id) + REFERENCES ser_torrents (torrent_id) + ON UPDATE CASCADE + ON DELETE CASCADE, + CONSTRAINT ser_peers_user_foreign + FOREIGN KEY (user_id) + REFERENCES ser_users (user_id) + ON UPDATE CASCADE + ON DELETE CASCADE + ) ENGINE=InnoDB COLLATE="utf8mb4_bin"; + '); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..61efd59 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 5 + paths: + - src + bootstrapFiles: + - seria.php diff --git a/public/_footer.php b/public/_footer.php deleted file mode 100644 index d60aa8d..0000000 --- a/public/_footer.php +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - diff --git a/public/_header.php b/public/_header.php deleted file mode 100644 index a1bf855..0000000 --- a/public/_header.php +++ /dev/null @@ -1,52 +0,0 @@ -isLoggedIn(); -?> - - - - - <?=$tPageTitle;?> - - - -
- -
diff --git a/public/announce.php b/public/announce.php deleted file mode 100644 index 0c2dd8d..0000000 --- a/public/announce.php +++ /dev/null @@ -1,144 +0,0 @@ - $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/seria.css b/public/assets/seria.css similarity index 96% rename from public/seria.css rename to public/assets/seria.css index c0dd1f9..35fffc9 100644 --- a/public/seria.css +++ b/public/assets/seria.css @@ -724,3 +724,30 @@ body, font-size: 1.2em; padding: 4px 10px; } + +.http-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; +} +.http-error-icon { + flex: 0 0 auto; + background: #561616; + width: 40px; + height: 40px; + text-align: center; + line-height: 38px; +} +.http-error-icon img { + vertical-align: middle; +} +.http-error-text { + flex: 1 1 auto; + font-size: 1.2em; + padding: 4px 10px; +} diff --git a/public/assets/seria.js b/public/assets/seria.js new file mode 100644 index 0000000..11c640b --- /dev/null +++ b/public/assets/seria.js @@ -0,0 +1,157 @@ +const xhr = (function() { + const send = function(method, url, options, body) { + options ??= {}; + + const xhr = new XMLHttpRequest; + const requestHeadersRaw = options?.headers ?? {}; + const requestHeaders = new Map; + + if(typeof requestHeadersRaw === 'object') + for(const name in requestHeadersRaw) + if(requestHeadersRaw.hasOwnProperty(name)) + requestHeaders.set(name.toLowerCase(), requestHeadersRaw[name]); + + if(typeof options.download === 'function') { + xhr.onloadstart = ev => options.download(ev); + xhr.onprogress = ev => options.download(ev); + xhr.onloadend = ev => options.download(ev); + } + + if(typeof options.upload === 'function') { + xhr.upload.onloadstart = ev => options.upload(ev); + xhr.upload.onprogress = ev => options.upload(ev); + xhr.upload.onloadend = ev => options.upload(ev); + } + + if(options.authed) + xhr.withCredentials = true; + + if(typeof options.timeout === 'number') + xhr.timeout = options.timeout; + + if(typeof options.abort === 'function') + options.abort(() => xhr.abort()); + + if(typeof options.xhr === 'function') + options.xhr(() => xhr); + + if(typeof body === 'object') { + if(body instanceof URLSearchParams) { + requestHeaders.set('content-type', 'application/x-www-form-urlencoded'); + } else if(body instanceof FormData) { + requestHeaders.set('content-type', 'multipart/form-data'); + } else if(body instanceof Blob || body instanceof ArrayBuffer || body instanceof DataView) { + if(!requestHeaders.has('content-type')) + requestHeaders.set('content-type', 'application/octet-stream'); + } else if(!requestHeaders.has('content-type')) { + const bodyParts = []; + for(const name in body) + if(body.hasOwnProperty(name)) + bodyParts.push(encodeURIComponent(name) + '=' + encodeURIComponent(body[name])); + body = bodyParts.join('&'); + requestHeaders.set('content-type', 'application/x-www-form-urlencoded'); + } + } + + return new Promise((resolve, reject) => { + let responseHeaders = undefined; + + xhr.onload = ev => resolve({ + status: xhr.status, + body: () => xhr.responseText, + json: () => JSON.parse(xhr.responseText), + headers: () => { + if(responseHeaders !== undefined) + return responseHeaders; + + responseHeaders = new Map; + + const raw = xhr.getAllResponseHeaders().trim().split(/[\r\n]+/); + for(const name in raw) + if(raw.hasOwnProperty(name)) { + const parts = raw[name].split(': '); + responseHeaders.set(parts.shift(), parts.join(': ')); + } + + return responseHeaders; + }, + xhr: xhr, + ev: ev, + }); + + xhr.onerror = ev => reject({ + xhr: xhr, + ev: ev, + }); + + xhr.open(method, url); + for(const [name, value] of requestHeaders) + xhr.setRequestHeader(name, value); + xhr.send(body); + }); + }; + + return { + send: send, + get: (url, options, body) => send('GET', url, options, body), + post: (url, options, body) => send('POST', url, options, body), + delete: (url, options, body) => send('DELETE', url, options, body), + patch: (url, options, body) => send('PATCH', url, options, body), + put: (url, options, body) => send('PUT', url, options, body), + }; +})(); + +const seria = (function() { + const csrfp = () => document.querySelector('meta[name="csrfp-token"]')?.content ?? ''; + + const dls = (function() { + return { + rehash: async dlId => { + const result = await xhr.post(`/info/${dlId}/rehash`, { authed: true }, { _csrfp: csrfp() }); + + if(result.status !== 200) { + if(result.status === 400) + return 'req'; + if(result.status === 401) + return 'auth'; + if(result.status === 403) + return 'priv'; + return 'unk'; + } + + return result.json(); + }, + approve: async dlId => { + const result = await xhr.post(`/info/${dlId}/approve`, { authed: true }, { _csrfp: csrfp() }); + + if(result.status === 204) + return ''; + if(result.status === 400) + return 'req'; + if(result.status === 401) + return 'auth'; + if(result.status === 403) + return 'priv'; + return 'unk'; + }, + deny: async dlId => { + const result = await xhr.post(`/info/${dlId}/deny`, { authed: true }, { _csrfp: csrfp() }); + + if(result.status === 204) + return ''; + if(result.status === 400) + return 'req'; + if(result.status === 401) + return 'auth'; + if(result.status === 403) + return 'priv'; + return 'unk'; + }, + }; + })(); + + return { + csrfp: csrfp, + dls: dls, + }; +})(); diff --git a/public/available.php b/public/available.php deleted file mode 100644 index 363f1ea..0000000 --- a/public/available.php +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 1dd2441..0000000 --- a/public/create.php +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 686f89f..0000000 --- a/public/download.php +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index 274ef10..0000000 --- a/public/history.php +++ /dev/null @@ -1,27 +0,0 @@ -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 index b70baa6..01af879 100644 --- a/public/index.php +++ b/public/index.php @@ -1,13 +1,25 @@ '; -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 '
'; +if($authInfo->success) { + $users = $seria->getUsersContext()->getUsers(); + $users->syncChatUser($authInfo); + $sUserInfo = $users->getUser($authInfo->user_id, 'id'); + $seria->getAuthInfo()->setInfo($sUserInfo); +} else $sUserInfo = null; -require_once __DIR__ . '/_footer.php'; +$seria->startCSRFP( + SERIA_CSRFP_SECRET, + $authInfo->success ? $authToken : (string)filter_input(INPUT_SERVER, 'REMOTE_ADDR') +); + +$seria->startTemplating(); +$seria->createRouting()->dispatch(); diff --git a/public/info.php b/public/info.php deleted file mode 100644 index 00d589c..0000000 --- a/public/info.php +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index 497806a..0000000 --- a/public/pending.php +++ /dev/null @@ -1,55 +0,0 @@ -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 deleted file mode 100644 index 9281356..0000000 --- a/public/profile.php +++ /dev/null @@ -1,71 +0,0 @@ -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/settings.php b/public/settings.php deleted file mode 100644 index 95b96ff..0000000 --- a/public/settings.php +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 69e3cea..0000000 --- a/public/test.php +++ /dev/null @@ -1,41 +0,0 @@ - -> 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 index df49960..73a9d1a 100644 --- a/seria.php +++ b/seria.php @@ -1,157 +1,25 @@ 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()); -} +$db = DbTools::create(SERIA_DSN); +$db->execute('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\';'); -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)) { - $loginMethod = 'Misuzu'; - $loginSignature = sprintf('verify#%s#%s#%s', $loginMethod, $mszAuth, $_SERVER['REMOTE_ADDR']); - $loginSignature = hash_hmac('sha256', $loginSignature, 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 => http_build_query([ - 'method' => $loginMethod, - 'token' => $mszAuth, - 'ipaddr' => $_SERVER['REMOTE_ADDR'], - ], '', '&', PHP_QUERY_RFC3986), - 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/x-www-form-urlencoded', - 'X-SharpChat-Signature: ' . $loginSignature, - ], - ]); - $loginResponse = json_decode(curl_exec($login)); - curl_close($login); - - if(!empty($loginResponse->success)) - $sUserInfo = SeriaUser::fromMisuzu($pdo, $loginResponse); - - unset($mszAuth, $loginMethod, $loginSignature, $loginMethod, $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'; -} +$seria = new SeriaContext($db); diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php new file mode 100644 index 0000000..a85aa26 --- /dev/null +++ b/src/Auth/AuthInfo.php @@ -0,0 +1,43 @@ +setInfo(); + } + + public function setInfo( + ?UserInfo $userInfo = null + ): void { + $this->userInfo = $userInfo; + } + + public function removeInfo(): void { + $this->setInfo(); + } + + public function isLoggedIn(): bool { + return $this->userInfo !== null; + } + + public function getUserId(): ?string { + return $this->userInfo?->getId(); + } + + public function getUserName(): ?string { + return $this->userInfo?->getName(); + } + + public function getUserColour(): ?Colour { + return $this->userInfo?->getColour(); + } + + public function getUserInfo(): ?UserInfo { + return $this->userInfo; + } +} diff --git a/src/Auth/ChatAuth.php b/src/Auth/ChatAuth.php new file mode 100644 index 0000000..8ed0e4e --- /dev/null +++ b/src/Auth/ChatAuth.php @@ -0,0 +1,54 @@ + false, + CURLOPT_FAILONERROR => false, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_HEADER => false, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => http_build_query([ + 'method' => $method, + 'token' => $cookie, + 'ipaddr' => $_SERVER['REMOTE_ADDR'], + ], '', '&', PHP_QUERY_RFC3986), + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TCP_FASTOPEN => true, + CURLOPT_CONNECTTIMEOUT => 2, + CURLOPT_MAXREDIRS => 2, + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, + CURLOPT_TIMEOUT => 5, + CURLOPT_USERAGENT => 'Seria', + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/x-www-form-urlencoded', + 'X-SharpChat-Signature: ' . $signature, + ], + ]); + $userInfo = json_decode(curl_exec($login)); + curl_close($login); + } + + if(empty($userInfo->success)) { + $userInfo = new stdClass; + $userInfo->success = false; + $userInfo->user_id = 0; + $userInfo->username = 'Anonymous'; + $userInfo->colour_raw = 0x40000000; + $userInfo->rank = 0; + $userInfo->hierarchy = 0; + $userInfo->perms = 0; + } + + return $userInfo; + } +} diff --git a/src/Colours.php b/src/Colours.php new file mode 100644 index 0000000..6499cfc --- /dev/null +++ b/src/Colours.php @@ -0,0 +1,60 @@ + 1) + return Colour::mix( + $warning, + self::cached(self::RATIO_GOOD), + XNumber::easeInQuad($ratio - 1) + ); + + return Colour::mix( + self::cached(self::RATIO_BAD), + $warning, + XNumber::easeOutQuad($ratio) + ); + } + + public static function forFileSize(int $bytes): Colour { + // should this ratio as well? + if($bytes >= 53687090000) + return self::cached(self::FILE_OVER_50GB); + if($bytes >= 21474840000) + return self::cached(self::FILE_UP_TO_50GB); + if($bytes >= 10737420000) + return self::cached(self::FILE_UP_TO_20GB); + if($bytes >= 5368709000) + return self::cached(self::FILE_UP_TO_10GB); + if($bytes >= 1073742000) + return self::cached(self::FILE_UP_TO_5GB); + return self::cached(self::FILE_UP_TO_1GB); + } + + public static function cached(int $raw): Colour { + if(array_key_exists($raw, self::$colourCache)) + return self::$colourCache[$raw]; + return self::$colourCache[$raw] = ColourRGB::fromRawRGB($raw); + } +} diff --git a/src/GitInfo.php b/src/GitInfo.php new file mode 100644 index 0000000..e6f4f25 --- /dev/null +++ b/src/GitInfo.php @@ -0,0 +1,24 @@ +templating->render('index'); + } + + #[Route('GET', '/index.php')] + public function getIndexPHP($response): void { + $response->redirect('/', true); + } +} diff --git a/src/RoutingContext.php b/src/RoutingContext.php new file mode 100644 index 0000000..20008cf --- /dev/null +++ b/src/RoutingContext.php @@ -0,0 +1,49 @@ +router = new HttpFx; + $this->router->use('/', fn($resp) => $resp->setPoweredBy('Seria')); + } + + public function getRouter(): IRouter { + return $this->router; + } + + public function registerDefaultErrorPages(): void { + $this->router->setDefaultErrorHandler($this->defaultErrorHandler(...)); + $this->router->addErrorHandler(500, fn($resp) => $resp->setContent(file_get_contents(SERIA_DIR_TEMPLATES . '/500.html'))); + } + + public function defaultErrorHandler( + HttpResponseBuilder $responseBuilder, + HttpRequest $request, + int $code, + string $message + ): void { + // todo: render using templating + $responseBuilder->setTypeHTML(); + $responseBuilder->setContent(sprintf( + '%1$03d %2$s

%1$03d %2$s


Seria
', + $code, + $message + )); + } + + public function register(IRouteHandler $handler): void { + $this->router->register($handler); + } + + public function dispatch(?HttpRequest $request = null): void { + $this->router->dispatch($request); + } +} diff --git a/src/SeriaContext.php b/src/SeriaContext.php new file mode 100644 index 0000000..9406de6 --- /dev/null +++ b/src/SeriaContext.php @@ -0,0 +1,117 @@ +dbConn = $dbConn; + + $this->authInfo = new AuthInfo; + $this->siteInfo = new SiteInfo; + + $this->torrentsCtx = new TorrentsContext($dbConn); + $this->usersCtx = new UsersContext($dbConn); + } + + public function getDbConn(): IDbTransactions { + return $this->dbConn; + } + + public function getDbQueryCount(): int { + $result = $this->dbConn->query('SHOW SESSION STATUS LIKE "Questions"'); + return $result->next() ? $result->getInteger(1) : 0; + } + + public function createMigrationManager(): DbMigrationManager { + return new DbMigrationManager($this->dbConn, 'ser_' . DbMigrationManager::DEFAULT_TABLE); + } + + public function createMigrationRepo(): IDbMigrationRepo { + return new FsDbMigrationRepo(SERIA_DIR_MIGRATIONS); + } + + public function getAuthInfo(): AuthInfo { + return $this->authInfo; + } + + public function getSiteInfo(): SiteInfo { + return $this->siteInfo; + } + + public function getTorrentsContext(): TorrentsContext { + return $this->torrentsCtx; + } + + public function getUsersContext(): UsersContext { + return $this->usersCtx; + } + + public function getTemplating(): ?SasaeEnvironment { + return $this->templating; + } + + public function getCSRFP(): CSRFP { + return $this->csrfp; + } + + public function startCSRFP(string $secretKey, string $identity): void { + $this->csrfp = new CSRFP($secretKey, $identity); + } + + public function startTemplating(): void { + $isDebug = Environment::isDebug(); + $globals = [ + 'auth_info' => $this->authInfo, + 'site_info' => $this->siteInfo, + 'display_timings_info' => $isDebug, // + isFlash + ]; + + $this->templating = new SasaeEnvironment( + SERIA_DIR_TEMPLATES, + cache: $isDebug ? null : ['Seria', GitInfo::hash(true)], + debug: $isDebug, + ); + $this->templating->addExtension(new SeriaSasaeExtension($this)); + $this->templating->addGlobal('globals', $globals); + } + + public function createRouting(): RoutingContext { + $routing = new RoutingContext; + $routing->registerDefaultErrorPages(); + + $self = $this; + $router = $routing->getRouter(); + + $routing->register(new HomeRoutes($this->templating)); + $routing->register(new Users\ProfileRoutes($this->authInfo, $this->torrentsCtx, $this->usersCtx, $this->templating)); + $routing->register(new Users\SettingsRoutes($this->authInfo, $this->templating)); + $routing->register(new Torrents\AnnounceRouting($this->torrentsCtx, $this->usersCtx)); + $routing->register(new Torrents\TorrentCreateRouting($this->dbConn, $this->authInfo, $this->torrentsCtx, $this->csrfp, $this->templating)); + $routing->register(new Torrents\TorrentInfoRouting($this->authInfo, $this->torrentsCtx, $this->usersCtx, $this->csrfp, $this->templating)); + $routing->register(new Torrents\TorrentListRouting($this->authInfo, $this->torrentsCtx, $this->usersCtx, $this->templating)); + + return $routing; + } +} diff --git a/src/SeriaSasaeExtension.php b/src/SeriaSasaeExtension.php new file mode 100644 index 0000000..4bfab06 --- /dev/null +++ b/src/SeriaSasaeExtension.php @@ -0,0 +1,71 @@ +peers = $ctx->getTorrentsContext()->getPeers(); + } + + public function getFunctions() { + return [ + new TwigFunction('csrfp_token', $this->ctx->getCSRFP()->createToken(...)), + new TwigFunction('git_commit_hash', GitInfo::hash(...)), + new TwigFunction('git_tag', GitInfo::tag(...)), + new TwigFunction('git_branch', GitInfo::branch(...)), + new TwigFunction('seria_startup_time', fn(float $time = SERIA_STARTUP) => microtime(true) - $time), + new TwigFunction('seria_db_query_count', $this->ctx->getDbQueryCount(...)), + new TwigFunction('seria_header_menu', $this->getHeaderMenu(...)), + new TwigFunction('seria_ratio_colour', Colours::forRatio(...)), + new TwigFunction('seria_filesize_colour', Colours::forFileSize(...)), + new TwigFunction('seria_count_user_uploading', $this->peers->countUserUploading(...)), + new TwigFunction('seria_count_user_downloading', $this->peers->countUserDownloading(...)), + ]; + } + + public function getHeaderMenu(): array { + $menu = []; + $authInfo = $this->ctx->getAuthInfo(); + + if($authInfo->isLoggedIn()) + $menu[] = [ + 'text' => 'Settings', + 'url' => '/settings', + ]; + else + $menu[] = [ + 'text' => 'Log in', + 'url' => SERIA_CAUTH_LOGIN, + ]; + + $menu[] = [ + 'text' => 'Available Downloads', + 'url' => '/available', + ]; + + if($authInfo->isLoggedIn()) { + $userInfo = $authInfo->getUserInfo(); + + if($userInfo->canCreateTorrents()) + $menu[] = [ + 'text' => 'Create Torrent', + 'url' => '/create', + ]; + + if($userInfo->canApproveTorrents()) + $menu[] = [ + 'text' => 'Pending Torrents', + 'url' => '/pending', + ]; + } + + return $menu; + } +} diff --git a/src/SiteInfo.php b/src/SiteInfo.php new file mode 100644 index 0000000..d842319 --- /dev/null +++ b/src/SiteInfo.php @@ -0,0 +1,34 @@ +getId(); + + return sprintf(SERIA_PROFILE_FORMAT, $userInfo); + } + + public function getAvatarUrl(UserInfo|string $userInfo, int $res = 0): string { + if($userInfo instanceof UserInfo) + $userInfo = $userInfo->getId(); + + return sprintf($res < 1 ? SERIA_AVATAR_FORMAT : SERIA_AVATAR_FORMAT_RES, $userInfo, $res); + } +} diff --git a/src/Torrents/AnnounceEmpty.php b/src/Torrents/AnnounceEmpty.php new file mode 100644 index 0000000..e3cc0b1 --- /dev/null +++ b/src/Torrents/AnnounceEmpty.php @@ -0,0 +1,20 @@ + 0, + 'min interval' => 0, + 'complete' => 0, + 'incomplete' => 0, + 'peers' => $this->compactPeers ? '' : [], + ]; + } +} diff --git a/src/Torrents/AnnounceFailure.php b/src/Torrents/AnnounceFailure.php new file mode 100644 index 0000000..84f7f3f --- /dev/null +++ b/src/Torrents/AnnounceFailure.php @@ -0,0 +1,12 @@ + $this->reason]; + } +} diff --git a/src/Torrents/AnnounceInfo.php b/src/Torrents/AnnounceInfo.php new file mode 100644 index 0000000..d2591bc --- /dev/null +++ b/src/Torrents/AnnounceInfo.php @@ -0,0 +1,77 @@ +peers as $peerInfo) { + $peerInfo->isSeed() ? ++$this->completePeers : ++$this->incompletePeers; + $peers .= $peerInfo->getAddressRaw() . pack('n', $peerInfo->getPort()); + } + return $peers; + } + + private function createPeersListWithIds(): array { + $peers = []; + foreach($this->peers as $peerInfo) { + $peerInfo->isSeed() ? ++$this->completePeers : ++$this->incompletePeers; + $peers[] = [ + 'peer id' => $peerInfo->getId(), + 'ip' => $peerInfo->getAddress(), + 'port' => $peerInfo->getPort(), + ]; + } + + return $peers; + } + + private function createPeersListWithoutIds(): array { + $peers = []; + foreach($this->peers as $peerInfo) { + $peerInfo->isSeed() ? ++$this->completePeers : ++$this->incompletePeers; + $peers[] = [ + 'ip' => $peerInfo->getAddress(), + 'port' => $peerInfo->getPort(), + ]; + } + + return $peers; + } + + public function createPeersList(): array|string { + if($this->compactPeers) + return $this->createCompactPeersList(); + if($this->noPeerIds) + return $this->createPeersListWithoutIds(); + return $this->createPeersListWithIds(); + } + + public function bencodeSerialise(): mixed { + $peers = $this->createPeersList(); + + return [ + 'interval' => $this->interval, + 'min interval' => $this->minInterval, + 'complete' => $this->completePeers, + 'incomplete' => $this->incompletePeers, + //'downloaded' => 0, todo: keep track of how many completes + 'peers' => $peers, + //'peers6' => '', todo: support ipv6 someday + ]; + } +} diff --git a/src/Torrents/AnnounceRouting.php b/src/Torrents/AnnounceRouting.php new file mode 100644 index 0000000..9735d7a --- /dev/null +++ b/src/Torrents/AnnounceRouting.php @@ -0,0 +1,144 @@ +torrentsCtx->getTorrents(); + $peers = $this->torrentsCtx->getPeers(); + $users = $this->usersCtx->getUsers(); + + $userInfo = null; + if($key !== '') + try { + $userInfo = $users->getUser($key, 'passkey'); + } catch(RuntimeException $ex) { + sleep(3); + return new AnnounceFailure('Authentication failed.'); + } + + $infoHash = (string)$request->getParam('info_hash'); + if($infoHash === '') + return new AnnounceFailure('Missing info hash.'); + if(strlen($infoHash) !== 20) + return new AnnounceFailure('Invalid info hash.'); + + $peerId = (string)$request->getParam('peer_id'); + if($peerId === '') + return new AnnounceFailure('Missing peer id.'); + if(strlen($peerId) !== 20) + return new AnnounceFailure('Invalid peer id.'); + + $address = $_SERVER['REMOTE_ADDR']; + + $port = (int)$request->getParam('port'); + if($port < 1 || $port > 0xFFFF) + return new AnnounceFailure('Invalid port number.'); + + $key = (string)$request->getParam('key'); + if(strlen($key) > 128) + return new AnnounceFailure('Key is ridiculous.'); + + $event = (string)$request->getParam('event'); + if(strlen($event) > 128) + return new AnnounceFailure('Event is fucked up.'); + + $userAgent = (string)$request->getHeaderLine('User-Agent'); + if(strlen($userAgent) > 255) + return new AnnounceFailure('Agent name is stupid.'); + + $noPeerId = !empty($request->getParam('no_peer_id')); + $compact = !empty($request->getParam('compact')); + + $bytesUploaded = (int)$request->getParam('uploaded', FILTER_SANITIZE_NUMBER_INT); + $bytesDownloaded = (int)$request->getParam('downloaded', FILTER_SANITIZE_NUMBER_INT); + $bytesRemaining = (int)$request->getParam('left', FILTER_SANITIZE_NUMBER_INT); + + // handle this, return error if too many + $wantsPeerAmount = (int)$request->getParam('numwant', FILTER_SANITIZE_NUMBER_INT); + + try { + $torrentInfo = $torrents->getTorrent($infoHash, 'hash'); + } catch(RuntimeException $ex) { + return new AnnounceFailure('Info hash not found.'); + } + + $canDownload = $this->torrentsCtx->canDownloadTorrent($torrentInfo, $userInfo); + if($canDownload !== '') + return new AnnounceFailure(match($canDownload) { + 'inactive' => 'This package is inactive.', + 'private' => 'You must be logged in for this package.', + 'pending' => 'This package is pending approval.', + default => $canDownload, + }); + + $peerInfo = $peers->getPeer($torrentInfo, $peerId); + + if($peerInfo === null) { + // could probably skip this is the event is 'stopped' + $peerInfo = $peers->createPeer( + $torrentInfo, $userInfo, $peerId, $address, $port, self::INTERVAL, + $userAgent, $key, $bytesUploaded, $bytesDownloaded, $bytesRemaining + ); + } else { + if(!$peerInfo->verifyKey($key)) { + sleep(3); + return new AnnounceFailure('Peer verification failed.'); + } + + if(!$peerInfo->verifyUser($userInfo)) { + sleep(3); + return new AnnounceFailure('User verification failed.'); + } + + if($userInfo !== null) + $users->incrementTransferStats( + $userInfo, + $bytesDownloaded - $peerInfo->getBytesDownloaded(), + $bytesUploaded - $peerInfo->getBytesUploaded() + ); + + $peers->updatePeer( + $torrentInfo, $peerInfo, $address, $port, self::INTERVAL, + $userAgent, $bytesUploaded, $bytesDownloaded, $bytesRemaining + ); + } + + if($event === 'stopped') { + $peers->deletePeer($torrentInfo, $peerInfo); + return new AnnounceEmpty($compact); + } + + $peers->pruneExpiredPeers(); + + $peerInfos = $peers->getPeers($torrentInfo); + + return new AnnounceInfo( + $peerInfos, + self::INTERVAL, + self::INTERVAL_MIN, + $compact, + $noPeerId + ); + } +} diff --git a/src/Torrents/TorrentBuilder.php b/src/Torrents/TorrentBuilder.php new file mode 100644 index 0000000..51b8c64 --- /dev/null +++ b/src/Torrents/TorrentBuilder.php @@ -0,0 +1,205 @@ +created = time(); + } + + public function setUser(?UserInfo $userInfo): self { + return $this->setUserId($userInfo?->getId()); + } + + 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 RuntimeException('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::encode($info), true); + } + + public function create(IDbTransactions $dbConn, TorrentsContext $torrentsCtx): string { + $torrents = $torrentsCtx->getTorrents(); + $pieces = $torrentsCtx->getPieces(); + $files = $torrentsCtx->getFiles(); + + $dbConn->beginTransaction(); + + try { + $infoHash = $this->calculateInfoHash(); + $torrentId = $torrents->createTorrent( + $this->userId, $infoHash, $this->name, $this->created, + $this->pieceLength, $this->isPrivate, $this->comment + ); + + foreach($this->files as $file) + $files->createFile($torrentId, $file['length'], $file['path']); + + foreach($this->pieces as $piece) + $pieces->createPiece($torrentId, $piece); + + $dbConn->commit(); + } catch(Exception $ex) { + $dbConn->rollBack(); + throw $ex; + } + + return $torrentId; + } + + public static function import( + TorrentInfo $torrent, + array $pieces, + array $files + ): self { + $builder = new TorrentBuilder; + $builder->setUserId($torrent->getUserId()); + $builder->setName($torrent->getName()); + $builder->setPieceLength($torrent->getPieceLength()); + $builder->setPrivate($torrent->isPrivate()); + $builder->setCreatedTime($torrent->getCreatedTime()); + $builder->setComment($torrent->getComment()); + + foreach($pieces as $piece) + $builder->addPiece($piece->getHash()); + + 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 = Bencode::decode($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 TorrentBuilder; + $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; + } +} diff --git a/src/Torrents/TorrentCreateRouting.php b/src/Torrents/TorrentCreateRouting.php new file mode 100644 index 0000000..b885675 --- /dev/null +++ b/src/Torrents/TorrentCreateRouting.php @@ -0,0 +1,102 @@ +authInfo->isLoggedIn()) + return 403; + if(!$this->authInfo->getUserInfo()->canCreateTorrents()) + return 403; + } + + #[Route('GET', '/create')] + public function getCreate($response, $request) { + $template = $this->templating->load('create'); + + if($request->hasParam('error')) + $template->setVar('error', match((string)$request->getParam('error')) { + 'file' => 'No file supplied.', + 'verify' => 'Request verification failed.', + 'size' => 'File is too large.', + 'format' => 'Supplied file is not a valid .torrent file.', + default => 'An unknown error occurred.', + }); + + return $template; + } + + #[Route('POST', '/create')] + public function postCreate($response, $request) { + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + if(!$content->hasUploadedFile('torrent')) { + $response->redirect('/create?error=file'); + return; + } + + if(!$this->csrfp->verifyToken((string)$content->getParam('_csrfp'))) { + $response->redirect('/create?error=verify'); + return; + } + + $torrent = $content->getUploadedFile('torrent'); + + $error = $torrent->getErrorCode(); + if($error !== UPLOAD_ERR_OK) { + $response->redirect('/create?error=' . match($error) { + UPLOAD_ERR_NO_FILE => 'file', + UPLOAD_ERR_INI_SIZE => 'size', + UPLOAD_ERR_FORM_SIZE => 'size', + default => 'error', + }); + return; + } + + $path = $torrent->getLocalFileName(); + if($path === null || !is_file($path)) { + $response->redirect('/create?error=file'); + return; + } + + $file = fopen($path, 'rb'); + try { + $torrentBuilder = TorrentBuilder::decode($file); + $torrentBuilder->setUser($this->authInfo->getUserInfo()); + $torrentId = $torrentBuilder->create($this->dbConn, $this->torrentsCtx); + } catch(Exception $ex) { + $response->redirect('/create?error=format'); + return; + } finally { + if(is_resource($file)) + fclose($file); + } + + $response->redirect(sprintf('/info/%s', $torrentId)); + } + + #[Route('GET', '/create.php')] + public function getCreatePHP($response, $request) { + $response->redirect('/create', true); + } +} diff --git a/src/Torrents/TorrentFileInfo.php b/src/Torrents/TorrentFileInfo.php new file mode 100644 index 0000000..f28f1c9 --- /dev/null +++ b/src/Torrents/TorrentFileInfo.php @@ -0,0 +1,42 @@ +id = $result->getString(0); + $this->torrentId = $result->getString(1); + $this->length = $result->getInteger(2); + $this->path = $result->getString(3); + } + + public function getId(): string { + return $this->id; + } + + public function getTorrentId(): string { + return $this->torrentId; + } + + public function getLength(): int { + return $this->length; + } + + public function getPath(): string { + return $this->path; + } + + public function bencodeSerialise(): mixed { + return [ + 'length' => $this->length, + 'path' => explode('/', $this->path), + ]; + } +} diff --git a/src/Torrents/TorrentFiles.php b/src/Torrents/TorrentFiles.php new file mode 100644 index 0000000..407603c --- /dev/null +++ b/src/Torrents/TorrentFiles.php @@ -0,0 +1,49 @@ +cache = new DbStatementCache($dbConn); + } + + public function getFiles(TorrentInfo|string $torrentInfo): array { + $stmt = $this->cache->get('SELECT file_id, torrent_id, file_length, file_path FROM ser_torrents_files WHERE torrent_id = ? ORDER BY file_id ASC'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + $files = []; + + while($result->next()) + $files[] = new TorrentFileInfo($result); + + return $files; + } + + public function createFile( + TorrentInfo|string $torrentInfo, + int $length, + array|string $path + ): void { + $stmt = $this->cache->get('INSERT INTO ser_torrents_files (torrent_id, file_length, file_path) VALUES (?, ?, ?)'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $length); + $stmt->addParameter(3, is_array($path) ? implode('/', $path) : $path); + $stmt->execute(); + } + + public function countTotalSize(TorrentInfo|string $torrentInfo): int { + $stmt = $this->cache->get('SELECT SUM(file_length) FROM ser_torrents_files WHERE torrent_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } +} diff --git a/src/Torrents/TorrentInfo.php b/src/Torrents/TorrentInfo.php new file mode 100644 index 0000000..8c92dae --- /dev/null +++ b/src/Torrents/TorrentInfo.php @@ -0,0 +1,82 @@ +id = $result->getString(0); + $this->userId = $result->isNull(1) ? null : $result->getString(1); + $this->infoHash = $result->getString(2); + $this->active = $result->getInteger(3); // i don't think anything uses this field + $this->name = $result->getString(4); + $this->created = $result->getInteger(5); + $this->approved = $result->isNull(6) ? null : $result->getInteger(6); + $this->pieceLength = $result->getInteger(7); + $this->private = $result->getInteger(8); + $this->comment = $result->getString(9); + } + + public function getId(): string { + return $this->id; + } + + public function hasUser(): bool { + return $this->userId !== null; + } + + public function getUserId(): ?string { + return $this->userId; + } + + public function getHash(): string { + return $this->infoHash; + } + + public function isActive(): bool { + return $this->active !== 0; + } + + public function getName(): string { + return $this->name; + } + + public function getCreatedTime(): int { + return $this->created; + } + + public function getApprovedTime(): ?int { + return $this->approved; + } + + public function isApproved(): bool { + return $this->approved !== null; + } + + public function getPieceLength(): int { + return $this->pieceLength; + } + + public function isPrivate(): bool { + return $this->private !== 0; + } + + public function hasComment(): bool { + return $this->comment !== ''; + } + + public function getComment(): string { + return $this->comment; + } +} diff --git a/src/Torrents/TorrentInfoRouting.php b/src/Torrents/TorrentInfoRouting.php new file mode 100644 index 0000000..90b4b8b --- /dev/null +++ b/src/Torrents/TorrentInfoRouting.php @@ -0,0 +1,184 @@ +torrentsCtx->getTorrents()->getTorrent($torrentId); + } catch(RuntimeException $ex) { + $response->setStatusCode(404); + return 'Download not found.'; + } + + $canDownload = $this->torrentsCtx->canDownloadTorrent($torrentInfo, $this->authInfo->getUserInfo()); + if($canDownload !== '') { + $response->setStatusCode(403); + + return match($canDownload) { + 'inactive' => 'This download is inactive.', + 'private' => 'You must be logged in for this download.', + 'pending' => 'This download is pending approval.', + default => $canDownload, + }; + } + + $trackerUrl = SERIA_ANNOUNCE_URL_ANON; + if($this->authInfo->isLoggedIn()) { + $userInfo = $this->authInfo->getUserInfo(); + $passKey = $userInfo->hasPassKey() + ? $userInfo->getPassKey() + : $this->usersCtx->getUsers()->updatePassKey($userInfo); + + $trackerUrl = sprintf(SERIA_ANNOUNCE_URL, $passKey); + } + + $response->setContentType('application/x-bittorrent'); + $response->setFileName(htmlspecialchars($torrentInfo->getName()) . '.torrent'); + + return $this->torrentsCtx->encodeTorrent($torrentInfo, $trackerUrl); + } + + private function getTorrentInfo(string $torrentId): int { + if($this->torrentInfo?->getId() === $torrentId) + return 0; + + try { + $this->torrentInfo = $this->torrentsCtx->getTorrents()->getTorrent($torrentId); + } catch(RuntimeException $ex) { + return 404; + } + + $canDownload = $this->torrentsCtx->canDownloadTorrent($this->torrentInfo, $this->authInfo->getUserInfo()); + if($canDownload !== '') + return 403; + + return 0; + } + + #[Route('GET', '/info/:id')] + public function getInfo($response, $request, string $torrentId) { + $error = $this->getTorrentInfo($torrentId); + if($error > 0) return $error; + + if($this->torrentInfo->hasUser()) { + $users = $this->usersCtx->getUsers(); + $userInfo = $users->getUser($this->torrentInfo->getUserId(), 'id'); + } else $userInfo = null; + + $peers = $this->torrentsCtx->getPeers(); + $completePeers = $peers->countCompletePeers($this->torrentInfo); + $incompletePeers = $peers->countIncompletePeers($this->torrentInfo); + + $totalFileSize = $this->torrentsCtx->getFiles()->countTotalSize($this->torrentInfo); + + return $this->templating->render('info', [ + 'torrent_info' => $this->torrentInfo, + 'torrent_user' => $userInfo, + 'torrent_total_size' => $totalFileSize, + 'torrent_complete_peers' => $completePeers, + 'torrent_incomplete_peers' => $incompletePeers, + ]); + } + + #[Route('/info/:id/rehash')] + #[Route('/info/:id/approve')] + #[Route('/info/:id/deny')] + public function verifyRequest($response, $request, string $torrentId) { + if(!$this->authInfo->isLoggedIn()) + return 401; + + if(!$request->isFormContent()) + return 400; + + $content = $request->getContent(); + if(!$this->csrfp->verifyToken((string)$content->getParam('_csrfp'))) + return 403; + + $error = $this->getTorrentInfo($torrentId); + if($error > 0) return $error; + } + + #[Route('POST', '/info/:id/rehash')] + public function postRehash($response, $request, string $torrentId) { + $error = $this->getTorrentInfo($torrentId); + if($error > 0) return $error; + + if(!$this->authInfo->getUserInfo()->canRecalculateInfoHash()) + return 403; + + $builder = TorrentBuilder::import( + $this->torrentInfo, + $this->torrentsCtx->getPieces()->getPieces($this->torrentInfo), + $this->torrentsCtx->getFiles()->getFiles($this->torrentInfo) + ); + $infoHash = $builder->calculateInfoHash(); + $this->torrentsCtx->getTorrents()->updateTorrentInfoHash($this->torrentInfo, $infoHash); + + return [ + 'hash' => base64_encode($infoHash), + ]; + } + + #[Route('POST', '/info/:id/approve')] + public function postApprove($response, $request, string $torrentId) { + $error = $this->getTorrentInfo($torrentId); + if($error > 0) return $error; + + if(!$this->authInfo->getUserInfo()->canApproveTorrents()) + return 403; + + $this->torrentsCtx->getTorrents()->approveTorrent($this->torrentInfo); + + return 204; + } + + #[Route('POST', '/info/:id/deny')] + public function postDeny($response, $request, string $torrentId) { + $error = $this->getTorrentInfo($torrentId); + if($error > 0) return $error; + + if(!$this->authInfo->getUserInfo()->canApproveTorrents()) + return 403; + + $this->torrentsCtx->getTorrents()->deleteTorrent($this->torrentInfo); + + return 204; + } + + #[Route('GET', '/info.php')] + public function getInfoPHP($response, $request) { + $torrentId = (int)$request->getParam('id', FILTER_SANITIZE_NUMBER_INT); + if($torrentId < 1) + return 404; + + $response->redirect(sprintf('/info/%d', $torrentId), true); + } + + #[Route('GET', '/download.php')] + public function getDownloadPHP($response, $request) { + $torrentId = (int)$request->getParam('id', FILTER_SANITIZE_NUMBER_INT); + if($torrentId < 1) + return 404; + + $response->redirect(sprintf('/download/%d', $torrentId), true); + } +} diff --git a/src/Torrents/TorrentListRouting.php b/src/Torrents/TorrentListRouting.php new file mode 100644 index 0000000..3bb7909 --- /dev/null +++ b/src/Torrents/TorrentListRouting.php @@ -0,0 +1,136 @@ +usersCtx->getUsers(); + $peers = $this->torrentsCtx->getPeers(); + + if($request->hasParam('name')) { + $userName = (string)$request->getParam('name'); + + try { + $userInfo = $users->getUser($userName, 'name'); + } catch(RuntimeException $ex) { + return 404; + } + } else $userInfo = null; + + $url = '/available?'; + if($userInfo !== null) + $url .= 'name=' . $userInfo->getName() . '&'; + + $startAt = -1; + $take = 20; + + if($request->hasParam('start')) + $startAt = (int)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT); + + $torrents = []; + $torrentInfos = $this->torrentsCtx->getTorrents()->getTorrents( + public: $this->authInfo->isLoggedIn() ? null : true, + approved: true, + userInfo: $userInfo, + startAt: $startAt, + take: $take + ); + foreach($torrentInfos as $torrentInfo) + $torrents[] = [ + 'info' => $torrentInfo, + 'user' => $torrentInfo->hasUser() ? $users->getUser($torrentInfo->getUserId(), 'id') : null, + 'complete_peers' => $peers->countCompletePeers($torrentInfo), + 'incomplete_peers' => $peers->countIncompletePeers($torrentInfo), + ]; + + return $this->templating->render('available', [ + 'page_url' => $url, + 'torrents' => $torrents, + 'filter_user' => $userInfo, + ]); + } + + #[Route('GET', '/pending')] + public function getPending($response, $request) { + if(!$this->authInfo->isLoggedIn()) + return 403; + if(!$this->authInfo->getUserInfo()->canApproveTorrents()) + return 403; + + $users = $this->usersCtx->getUsers(); + $peers = $this->torrentsCtx->getPeers(); + + $url = '/pending?'; + $startAt = -1; + $take = 20; + + if($request->hasParam('start')) + $startAt = (int)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT); + + $torrents = []; + $torrentInfos = $this->torrentsCtx->getTorrents()->getTorrents( + approved: false, + startAt: $startAt, + take: $take + ); + foreach($torrentInfos as $torrentInfo) + $torrents[] = [ + 'info' => $torrentInfo, + 'user' => $torrentInfo->hasUser() ? $users->getUser($torrentInfo->getUserId(), 'id') : null, + 'complete_peers' => $peers->countCompletePeers($torrentInfo), + 'incomplete_peers' => $peers->countIncompletePeers($torrentInfo), + ]; + + return $this->templating->render('pending', [ + 'page_url' => $url, + 'torrents' => $torrents, + ]); + } + + #[Route('GET', '/available.php')] + public function getAvailablePHP($response, $request): void { + $query = []; + + $name = (string)$request->getParam('name'); + if($name !== '') $query['name'] = $name; + + $start = (int)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT); + if($start > 0) $query['start'] = $start; + + $url = '/available'; + $query = http_build_query($query, encoding_type: PHP_QUERY_RFC3986); + if($query !== '') + $url .= '?' . $query; + + $response->redirect($url, true); + } + + #[Route('GET', '/pending.php')] + public function getPendingPHP($response, $request): void { + $query = []; + + $start = (int)$request->getParam('start', FILTER_SANITIZE_NUMBER_INT); + if($start > 0) $query['start'] = $start; + + $url = '/pending'; + $query = http_build_query($query, encoding_type: PHP_QUERY_RFC3986); + if($query !== '') + $url .= '?' . $query; + + $response->redirect($url, true); + } +} diff --git a/src/Torrents/TorrentPeerInfo.php b/src/Torrents/TorrentPeerInfo.php new file mode 100644 index 0000000..00d754b --- /dev/null +++ b/src/Torrents/TorrentPeerInfo.php @@ -0,0 +1,108 @@ +id = $result->getString(0); + $this->torrentId = $result->getString(1); + $this->userId = $result->isNull(2) ? null : $result->getString(2); + $this->address = $result->getString(3); + $this->port = $result->getInteger(4); + $this->updated = $result->getInteger(5); + $this->expires = $result->getInteger(6); + $this->agent = $result->getString(7); + $this->key = $result->getString(8); + $this->bytesUploaded = $result->getInteger(9); + $this->bytesDownloaded = $result->getInteger(10); + $this->bytesLeft = $result->getInteger(11); + } + + public function getId(): string { + return $this->id; + } + + public function getTorrentId(): string { + return $this->torrentId; + } + + public function getUserId(): ?string { + return $this->userId; + } + + public function hasUserId(): bool { + return $this->userId !== null; + } + + public function verifyUser(UserInfo $userInfo): bool { + return !$this->hasUserId() + || ($userInfo !== null && $userInfo->getId() === $this->userId); + } + + public function getAddress(): string { + return $this->address; + } + + public function getAddressRaw(): string { + return inet_pton($this->address); + } + + public function getPort(): int { + return $this->port; + } + + public function getUpdatedTime(): int { + return $this->updated; + } + + public function getExpiresTime(): int { + return $this->expires; + } + + public function getAgent(): string { + return $this->agent; + } + + public function getKey(): string { + return $this->key; + } + + public function getBytesUploaded(): int { + return $this->bytesUploaded; + } + + public function getBytesDownloaded(): int { + return $this->bytesDownloaded; + } + + public function getBytesRemaining(): int { + return $this->bytesLeft; + } + + public function isSeed(): bool { + return $this->bytesLeft <= 0; + } + + public function isLeech(): bool { + return $this->bytesLeft > 0; + } + + public function verifyKey(string $key): bool { + return hash_equals($this->getKey(), $key); + } +} diff --git a/src/Torrents/TorrentPeers.php b/src/Torrents/TorrentPeers.php new file mode 100644 index 0000000..ade7d6d --- /dev/null +++ b/src/Torrents/TorrentPeers.php @@ -0,0 +1,142 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function pruneExpiredPeers(): void { + $this->dbConn->execute('DELETE FROM ser_torrents_peers WHERE peer_expires < NOW()'); + } + + public function getPeers(TorrentInfo|string $torrentInfo): array { + $stmt = $this->cache->get('SELECT peer_id, torrent_id, user_id, INET6_NTOA(peer_address), peer_port, UNIX_TIMESTAMP(peer_updated), UNIX_TIMESTAMP(peer_expires), peer_agent, peer_key, peer_uploaded, peer_downloaded, peer_left FROM ser_torrents_peers WHERE torrent_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + + $peers = []; + $result = $stmt->getResult(); + + while($result->next()) + $peers[] = new TorrentPeerInfo($result); + + return $peers; + } + + public function getPeer(TorrentInfo|string $torrentInfo, string $peerId): ?TorrentPeerInfo { + $stmt = $this->cache->get('SELECT peer_id, torrent_id, user_id, INET6_NTOA(peer_address), peer_port, UNIX_TIMESTAMP(peer_updated), UNIX_TIMESTAMP(peer_expires), peer_agent, peer_key, peer_uploaded, peer_downloaded, peer_left FROM ser_torrents_peers WHERE torrent_id = ? AND peer_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $peerId); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + return null; + + return new TorrentPeerInfo($result); + } + + public function createPeer( + TorrentInfo|string $torrentInfo, + UserInfo|string|null $userInfo, + string $peerId, + string $remoteAddr, + int $remotePort, + int $interval, + string $peerAgent, + string $peerKey, + int $bytesUploaded, + int $bytesDownloaded, + int $bytesRemaining + ): TorrentPeerInfo { + $stmt = $this->cache->get('INSERT INTO ser_torrents_peers (torrent_id, user_id, peer_id, peer_address, peer_port, peer_updated, peer_expires, peer_agent, peer_key, peer_uploaded, peer_downloaded, peer_left) VALUES (?, ?, ?, INET6_ATON(?), ?, NOW(), NOW() + INTERVAL ? SECOND, ?, ?, ?, ?, ?)'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $userInfo === null ? null : ($userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo)); + $stmt->addParameter(3, $peerId); + $stmt->addParameter(4, $remoteAddr); + $stmt->addParameter(5, $remotePort); + $stmt->addParameter(6, $interval); + $stmt->addParameter(7, $peerAgent); + $stmt->addParameter(8, $peerKey); + $stmt->addParameter(9, $bytesUploaded); + $stmt->addParameter(10, $bytesDownloaded); + $stmt->addParameter(11, $bytesRemaining); + $stmt->execute(); + + return $this->getPeer($torrentInfo, $peerId) ?? throw new RuntimeException('Failed to record peer information.'); + } + + public function updatePeer( + TorrentInfo|string $torrentInfo, + TorrentPeerInfo|string $peerInfo, + string $remoteAddr, + int $remotePort, + int $interval, + string $peerAgent, + int $bytesUploaded, + int $bytesDownloaded, + int $bytesRemaining + ): void { + $stmt = $this->cache->get('UPDATE ser_torrents_peers SET peer_address = INET6_ATON(?), peer_port = ?, peer_updated = NOW(), peer_expires = NOW() + INTERVAL ? SECOND, peer_agent = ?, peer_uploaded = ?, peer_downloaded = ?, peer_left = ? WHERE torrent_id = ? AND peer_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $peerInfo instanceof TorrentPeerInfo ? $peerInfo->getId() : $peerInfo); + $stmt->addParameter(3, $remoteAddr); + $stmt->addParameter(4, $remotePort); + $stmt->addParameter(5, $interval); + $stmt->addParameter(6, $peerAgent); + $stmt->addParameter(7, $bytesUploaded); + $stmt->addParameter(8, $bytesDownloaded); + $stmt->addParameter(9, $bytesRemaining); + $stmt->execute(); + } + + public function deletePeer(TorrentInfo|string $torrentInfo, TorrentPeerInfo|string $peerInfo): void { + $stmt = $this->cache->get('DELETE FROM ser_torrents_peers WHERE torrent_id = ? AND peer_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $peerInfo instanceof TorrentPeerInfo ? $peerInfo->getId() : $peerInfo); + $stmt->execute(); + } + + public function countIncompletePeers(TorrentInfo|string $torrentInfo): int { + $stmt = $this->cache->get('SELECT COUNT(*) FROM ser_torrents_peers WHERE torrent_id = ? AND peer_left > 0'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } + + public function countCompletePeers(TorrentInfo|string $torrentInfo): int { + $stmt = $this->cache->get('SELECT COUNT(*) FROM ser_torrents_peers WHERE torrent_id = ? AND peer_left <= 0'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } + + public function countUserDownloading(UserInfo|string $userInfo): int { + $stmt = $this->cache->get('SELECT COUNT(*) FROM ser_torrents_peers WHERE user_id = ? AND peer_left > 0'); + $stmt->addParameter(1, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo); + $stmt->execute(); + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } + + public function countUserUploading(UserInfo|string $userInfo): int { + $stmt = $this->cache->get('SELECT COUNT(*) FROM ser_torrents_peers WHERE user_id = ? AND peer_left <= 0'); + $stmt->addParameter(1, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo); + $stmt->execute(); + $result = $stmt->getResult(); + return $result->next() ? $result->getInteger(0) : 0; + } +} diff --git a/src/Torrents/TorrentPieceInfo.php b/src/Torrents/TorrentPieceInfo.php new file mode 100644 index 0000000..e4f5b8f --- /dev/null +++ b/src/Torrents/TorrentPieceInfo.php @@ -0,0 +1,28 @@ +id = $result->getString(0); + $this->torrentId = $result->getString(1); + $this->hash = $result->getString(2); + } + + public function getId(): string { + return $this->id; + } + + public function getTorrentId(): string { + return $this->torrentId; + } + + public function getHash(): string { + return $this->hash; + } +} diff --git a/src/Torrents/TorrentPieces.php b/src/Torrents/TorrentPieces.php new file mode 100644 index 0000000..e233443 --- /dev/null +++ b/src/Torrents/TorrentPieces.php @@ -0,0 +1,39 @@ +cache = new DbStatementCache($dbConn); + } + + public function getPieces(TorrentInfo|string $torrentInfo): array { + $stmt = $this->cache->get('SELECT piece_id, torrent_id, piece_hash FROM ser_torrents_pieces WHERE torrent_id = ? ORDER BY piece_id ASC'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + + $result = $stmt->getResult(); + $pieces = []; + + while($result->next()) + $pieces[] = new TorrentPieceInfo($result); + + return $pieces; + } + + public function createPiece( + TorrentInfo|string $torrentInfo, + string $hash + ): void { + $stmt = $this->cache->get('INSERT INTO ser_torrents_pieces (torrent_id, piece_hash) VALUES (?, ?)'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->addParameter(2, $hash); + $stmt->execute(); + } +} diff --git a/src/Torrents/Torrents.php b/src/Torrents/Torrents.php new file mode 100644 index 0000000..67ca169 --- /dev/null +++ b/src/Torrents/Torrents.php @@ -0,0 +1,161 @@ +dbConn = $dbConn; + $this->cache = new DbStatementCache($dbConn); + } + + public function getTorrents( + ?bool $public = null, + ?bool $approved = null, + UserInfo|string|null $userInfo = null, + int $startAt = -1, + int $take = -1 + ): array { + $hasPublic = $public !== null; + $hasApproved = $approved !== null; + $hasUserInfo = $userInfo !== null; + $hasStartAt = $startAt > 0; + $hasTake = $take > 0; + + $args = 0; + $query = 'SELECT torrent_id, user_id, torrent_hash, torrent_active, torrent_name, UNIX_TIMESTAMP(torrent_created), UNIX_TIMESTAMP(torrent_approved), torrent_piece_length, torrent_private, torrent_comment FROM ser_torrents'; + if($hasPublic) { + ++$args; + $query .= sprintf(' WHERE torrent_private %s 0', $public ? '=' : '<>'); + } + if($hasApproved) + $query .= sprintf(' %s torrent_approved %s NULL', ++$args > 1 ? 'AND' : 'WHERE', $approved ? 'IS NOT' : 'IS'); + if($hasUserInfo) + $query .= sprintf(' %s user_id = ?', ++$args > 1 ? 'AND' : 'WHERE'); + if($hasStartAt) + $query .= sprintf(' %s torrent_id < ?', ++$args > 1 ? 'AND' : 'WHERE'); + $query .= ' ORDER BY torrent_id DESC'; + if($hasTake) + $query .= ' LIMIT ?'; + + $args = 0; + $stmt = $this->cache->get($query); + if($hasUserInfo) + $stmt->addParameter(++$args, $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo); + if($hasStartAt) + $stmt->addParameter(++$args, $startAt); + if($hasTake) + $stmt->addParameter(++$args, $take); + $stmt->execute(); + + $torrents = []; + $result = $stmt->getResult(); + + while($result->next()) + $torrents[] = new TorrentInfo($result); + + return $torrents; + } + + public const GET_TORRENT_ID = 0x01; + public const GET_TORRENT_HASH = 0x02; + + private const GET_TORRENT_SELECT_ALIASES = [ + 'id' => self::GET_TORRENT_ID, + 'hash' => self::GET_TORRENT_HASH, + ]; + + public static function resolveGetTorrentSelectAlias(int|string $select): int { + if(is_string($select)) { + if(!array_key_exists($select, self::GET_TORRENT_SELECT_ALIASES)) + throw new InvalidArgumentException('Invalid $select alias.'); + $select = self::GET_TORRENT_SELECT_ALIASES[$select]; + } elseif($select === 0) + throw new InvalidArgumentException('$select may not be zero.'); + + return $select; + } + + public function getTorrent(string $value, int|string $select = self::GET_TORRENT_ID): TorrentInfo { + if($value === '') + throw new InvalidArgumentException('$value may not be empty.'); + + $select = self::resolveGetTorrentSelectAlias($select); + $selectId = ($select & self::GET_TORRENT_ID) > 0; + $selectHash = ($select & self::GET_TORRENT_HASH) > 0; + + if(!$selectId && !$selectHash) + throw new InvalidArgumentException('$select flagset is invalid.'); + + $args = 0; + $query = 'SELECT torrent_id, user_id, torrent_hash, torrent_active, torrent_name, UNIX_TIMESTAMP(torrent_created), UNIX_TIMESTAMP(torrent_approved), torrent_piece_length, torrent_private, torrent_comment FROM ser_torrents'; + if($selectId) { + ++$args; + $query .= ' WHERE torrent_id = ?'; + } + if($selectHash) + $query .= sprintf(' %s torrent_hash = ?', ++$args > 1 ? 'OR' : 'WHERE'); + + $args = 0; + $stmt = $this->cache->get($query); + if($selectId) + $stmt->addParameter(++$args, $value); + if($selectHash) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('Download info not found.'); + + return new TorrentInfo($result); + } + + public function createTorrent( + UserInfo|string|null $userInfo, + string $infoHash, + string $name, + int $created, + int $pieceLength, + bool $isPrivate, + string $comment + ): string { + $stmt = $this->cache->get('INSERT INTO ser_torrents (user_id, torrent_hash, torrent_name, torrent_created, torrent_piece_length, torrent_private, torrent_comment) VALUES (?, ?, ?, FROM_UNIXTIME(?), ?, ?, ?)'); + $stmt->addParameter(1, $userInfo === null ? null : ($userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo)); + $stmt->addParameter(2, $infoHash); + $stmt->addParameter(3, $name); + $stmt->addParameter(4, $created); + $stmt->addParameter(5, $pieceLength); + $stmt->addParameter(6, $isPrivate ? 1 : 0); + $stmt->addParameter(7, $comment); + $stmt->execute(); + + return (string)$this->dbConn->getLastInsertId(); + } + + public function updateTorrentInfoHash(TorrentInfo|string $torrentInfo, string $infoHash): void { + $stmt = $this->cache->get('UPDATE ser_torrents SET torrent_hash = ? WHERE torrent_id = ?'); + $stmt->addParameter(1, $infoHash); + $stmt->addParameter(2, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + } + + public function approveTorrent(TorrentInfo|string $torrentInfo): void { + $stmt = $this->cache->get('UPDATE ser_torrents SET torrent_approved = COALESCE(torrent_approved, NOW()) WHERE torrent_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + } + + public function deleteTorrent(TorrentInfo|string $torrentInfo): void { + $stmt = $this->cache->get('DELETE FROM ser_torrents WHERE torrent_id = ?'); + $stmt->addParameter(1, $torrentInfo instanceof TorrentInfo ? $torrentInfo->getId() : $torrentInfo); + $stmt->execute(); + } +} diff --git a/src/Torrents/TorrentsContext.php b/src/Torrents/TorrentsContext.php new file mode 100644 index 0000000..54125de --- /dev/null +++ b/src/Torrents/TorrentsContext.php @@ -0,0 +1,87 @@ +torrents = new Torrents($dbConn); + $this->files = new TorrentFiles($dbConn); + $this->peers = new TorrentPeers($dbConn); + $this->pieces = new TorrentPieces($dbConn); + } + + public function getTorrents(): Torrents { + return $this->torrents; + } + + public function getFiles(): TorrentFiles { + return $this->files; + } + + public function getPeers(): TorrentPeers { + return $this->peers; + } + + public function getPieces(): TorrentPieces { + return $this->pieces; + } + + public function canDownloadTorrent(TorrentInfo|string $torrentInfo, ?UserInfo $userInfo): string { + if(is_string($torrentInfo)) + $torrentInfo = $this->torrents->getTorrent($torrentInfo, 'id'); + + if(!$torrentInfo->isActive()) + return 'inactive'; + + if($torrentInfo->isPrivate() && $userInfo === null) + return 'private'; + + if(!$torrentInfo->isApproved() && ($userInfo !== null && !$userInfo->canApproveTorrents() && $torrentInfo->getUserId() !== $userInfo->getId())) + return 'pending'; + + return ''; + } + + public function encodeTorrent(TorrentInfo|string $torrentInfo, string $announceUrl): string { + if(is_string($torrentInfo)) + $torrentInfo = $this->torrents->getTorrent($torrentInfo, 'id'); + + $pieces = ''; + $pieceInfos = $this->pieces->getPieces($torrentInfo); + foreach($pieceInfos as $piece) + $pieces .= $piece->getHash(); + + // VERY IMPORTANT DETAIL: keep ordering identical to how it is in TorrentBuilder to not fuck up info hashes + // this should really be combined somehow + $info = [ + 'files' => $this->files->getFiles($torrentInfo), + 'name' => $torrentInfo->getName(), + 'piece length' => $torrentInfo->getPieceLength(), + 'pieces' => $pieces, + ]; + + if($torrentInfo->isPrivate()) + $info['private'] = 1; + + $data = [ + 'announce' => $announceUrl, + 'created by' => sprintf('Seria %s', GitInfo::version()), + 'creation date' => $torrentInfo->getCreatedTime(), + 'info' => $info, + ]; + + if($torrentInfo->hasComment()) + $data['comment'] = $torrentInfo->getComment(); + + return Bencode::encode($data); + } +} diff --git a/src/Users/ProfileRoutes.php b/src/Users/ProfileRoutes.php new file mode 100644 index 0000000..9754dfc --- /dev/null +++ b/src/Users/ProfileRoutes.php @@ -0,0 +1,87 @@ +authInfo->isLoggedIn()) + return 403; + + $users = $this->usersCtx->getUsers(); + $torrents = $this->torrentsCtx->getTorrents(); + $peers = $this->torrentsCtx->getPeers(); + + try { + $userInfo = $users->getUser($name, 'name'); + } catch(RuntimeException $ex) { + return 404; + } + + $submissions = $torrents->getTorrents(approved: true, userInfo: $userInfo, take: 3); + $uploading = $peers->countUserUploading($userInfo); + $downloading = $peers->countUserDownloading($userInfo); + + return $this->templating->render('profile', [ + 'profile_user' => $userInfo, + 'profile_submissions' => $submissions, + 'profile_uploading' => $uploading, + 'profile_downloading' => $downloading, + ]); + } + + #[Route('GET', '/profile/:name/history')] + public function getHistory($response, $request, string $name) { + if(!$this->authInfo->isLoggedIn()) + return 403; + + $users = $this->usersCtx->getUsers(); + + try { + $userInfo = $users->getUser($name, 'name'); + } catch(RuntimeException $ex) { + return 404; + } + + return $this->templating->render('history', [ + 'history_user_info' => $userInfo, + ]); + } + + #[Route('GET', '/profile.php')] + public function getProfilePHP($response, $request): void { + $response->redirect(sprintf('/profile/%s', (string)$request->getParam('name')), true); + } + + #[Route('GET', '/history.php')] + public function getHistoryPHP($response, $request) { + $userName = (string)$request->getParam('name'); + if($userName === '' && $this->authInfo->isLoggedIn()) + $userName = $this->authInfo->getUserName(); + if($userName === '') + return 404; + + $url = sprintf('/profile/%s/history', $userName); + + $filter = (string)$request->getParam('filter'); + if($filter !== '') + $url .= '?filter=' . $filter; + + $response->redirect($url, true); + } +} diff --git a/src/Users/SettingsRoutes.php b/src/Users/SettingsRoutes.php new file mode 100644 index 0000000..cd09ab7 --- /dev/null +++ b/src/Users/SettingsRoutes.php @@ -0,0 +1,27 @@ +authInfo->isLoggedIn()) + return 403; + + return $this->templating->render('settings'); + } + + #[Route('GET', '/settings.php')] + public function getSettingsPHP($response): void { + $response->redirect('/settings', true); + } +} diff --git a/src/Users/UserInfo.php b/src/Users/UserInfo.php new file mode 100644 index 0000000..d9b89f3 --- /dev/null +++ b/src/Users/UserInfo.php @@ -0,0 +1,101 @@ +id = $result->getString(0); + $this->name = $result->getString(1); + + $colour = $result->isNull(2) ? null : $result->getInteger(2); + $this->colour = $colour === null || ($colour & 0x40000000) ? null : $colour; + + $this->rank = $result->getInteger(3); + $this->perms = $result->getInteger(4); + $this->passKey = $result->isNull(5) ? null : $result->getString(5); + $this->bytesDownloaded = $result->getInteger(6); + $this->bytesUploaded = $result->getInteger(7); + } + + public function getId(): string { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function hasColour(): bool { + return $this->colour !== null; + } + + public function getColour(): Colour { + return $this->colour === null ? Colour::none() : Colours::cached($this->colour); + } + + public function getColourRaw(): ?int { + return $this->colour; + } + + public function getRank(): int { + return $this->rank; + } + + public function getPermsRaw(): int { + return $this->perms; + } + + public function hasPassKey(): bool { + return $this->passKey !== null; + } + + public function getPassKey(): ?string { + return $this->passKey; + } + + public function getBytesDownloaded(): int { + return $this->bytesDownloaded; + } + + public function getBytesUploaded(): int { + return $this->bytesUploaded; + } + + public function isFlash(): bool { + return $this->id === '1'; + } + + public function canCreateTorrents(): bool { + return $this->isFlash() + || $this->id === '32' + || $this->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; + } +} diff --git a/src/Users/Users.php b/src/Users/Users.php new file mode 100644 index 0000000..d2fa9e4 --- /dev/null +++ b/src/Users/Users.php @@ -0,0 +1,127 @@ +cache = new DbStatementCache($dbConn); + } + + public static function generatePassKey(): string { + return XString::random(48); + } + + public function syncChatUser(object $authInfo): void { + if(!$authInfo->success) + return; + + $userColourFixed = /*($authInfo->colour_raw & 0x40000000) ? null :*/ $authInfo->colour_raw; + $stmt = $this->cache->get('INSERT INTO ser_users (user_id, user_name, user_colour, user_rank, user_permissions) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE user_name = ?, user_colour = ?, user_rank = ?, user_permissions = ?'); + $stmt->addParameter(1, $authInfo->user_id); + $stmt->addParameter(2, $authInfo->username); + $stmt->addParameter(3, $userColourFixed); + $stmt->addParameter(4, $authInfo->rank); + $stmt->addParameter(5, $authInfo->perms); + $stmt->addParameter(6, $authInfo->username); + $stmt->addParameter(7, $userColourFixed); + $stmt->addParameter(8, $authInfo->rank); + $stmt->addParameter(9, $authInfo->perms); + $stmt->execute(); + } + + public const GET_USER_ID = 0x01; + public const GET_USER_NAME = 0x02; + public const GET_USER_PASSKEY = 0x04; + + private const GET_USER_SELECT_ALIASES = [ + 'id' => self::GET_USER_ID, + 'name' => self::GET_USER_NAME, + 'passkey' => self::GET_USER_PASSKEY, + ]; + + public static function resolveGetUserSelectAlias(int|string $select): int { + if(is_string($select)) { + if(!array_key_exists($select, self::GET_USER_SELECT_ALIASES)) + throw new InvalidArgumentException('Invalid $select alias.'); + $select = self::GET_USER_SELECT_ALIASES[$select]; + } elseif($select === 0) + throw new InvalidArgumentException('$select may not be zero.'); + + return $select; + } + + public function getUser(string $value, int|string $select = self::GET_USER_ID): UserInfo { + if($value === '') + throw new InvalidArgumentException('$value may not be empty.'); + + $select = self::resolveGetUserSelectAlias($select); + $selectId = ($select & self::GET_USER_ID) > 0; + $selectName = ($select & self::GET_USER_NAME) > 0; + $selectPassKey = ($select & self::GET_USER_PASSKEY) > 0; + + if(!$selectId && !$selectName && !$selectPassKey) + throw new InvalidArgumentException('$select flagset is invalid.'); + + $args = 0; + $query = 'SELECT user_id, user_name, user_colour, user_rank, user_permissions, user_pass_key, user_bytes_downloaded, user_bytes_uploaded FROM ser_users'; + if($selectId) { + ++$args; + $query .= ' WHERE user_id = ?'; + } + if($selectName) + $query .= sprintf(' %s user_name = ?', ++$args > 1 ? 'OR' : 'WHERE'); + if($selectPassKey) + $query .= sprintf(' %s user_pass_key = ?', ++$args > 1 ? 'OR' : 'WHERE'); + + $args = 0; + $stmt = $this->cache->get($query); + if($selectId) + $stmt->addParameter(++$args, $value); + if($selectName) + $stmt->addParameter(++$args, $value); + if($selectPassKey) + $stmt->addParameter(++$args, $value); + $stmt->execute(); + + $result = $stmt->getResult(); + if(!$result->next()) + throw new RuntimeException('User not found.'); + + return new UserInfo($result); + } + + public function updatePassKey(UserInfo|string $userInfo): string { + if($userInfo instanceof UserInfo) + $userInfo = $userInfo->getId(); + + $passKey = self::generatePassKey(); + $stmt = $this->cache->get('UPDATE ser_users SET user_pass_key = ? WHERE user_id = ?'); + $stmt->addParameter(1, $passKey); + $stmt->addParameter(2, $userInfo); + $stmt->execute(); + + return $passKey; + } + + public function incrementTransferStats(UserInfo|string $userInfo, int $bytesDownloaded, int $bytesUploaded): void { + if($userInfo instanceof UserInfo) + $userInfo = $userInfo->getId(); + + $stmt = $this->cache->get('UPDATE ser_users SET user_bytes_downloaded = user_bytes_downloaded + ?, user_bytes_uploaded = user_bytes_uploaded + ? WHERE user_id = ?'); + $stmt->addParameter(1, $bytesDownloaded); + $stmt->addParameter(2, $bytesUploaded); + $stmt->addParameter(3, $userInfo); + $stmt->execute(); + } +} diff --git a/src/Users/UsersContext.php b/src/Users/UsersContext.php new file mode 100644 index 0000000..dcf7f41 --- /dev/null +++ b/src/Users/UsersContext.php @@ -0,0 +1,16 @@ +users = new Users($dbConn); + } + + public function getUsers(): Users { + return $this->users; + } +} diff --git a/src/announce.php b/src/announce.php deleted file mode 100644 index 286ffd4..0000000 --- a/src/announce.php +++ /dev/null @@ -1,51 +0,0 @@ -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 deleted file mode 100644 index 2bbf12e..0000000 --- a/src/benben.php +++ /dev/null @@ -1,146 +0,0 @@ - $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 deleted file mode 100644 index 57fd23d..0000000 --- a/src/torrent.php +++ /dev/null @@ -1,877 +0,0 @@ -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 deleted file mode 100644 index a08e367..0000000 --- a/src/user.php +++ /dev/null @@ -1,217 +0,0 @@ -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; - } -} diff --git a/templates/available.twig b/templates/available.twig new file mode 100644 index 0000000..fbb9717 --- /dev/null +++ b/templates/available.twig @@ -0,0 +1,31 @@ +{% extends 'master.twig' %} +{% from 'macros.twig' import torrent_listing_entry %} + +{% if filter_user is null %} + {% set title = 'Available Downloads' %} +{% else %} + {% set title = 'Downloads submitted by ' ~ filter_user.name %} +{% endif %} + +{% set show_more = false %} +{% set last_id = 0 %} + +{% block content %} +
+
{{ title }}
+
+ {% if torrents is empty %} +
Sorry, nothing.
+ {% else %} + {% for torrent in torrents %} + {{ torrent_listing_entry(torrent, 'downloads-item') }} + {% set last_id = torrent.info.id %} + {% endfor %} + {% endif %} +
+ + {% if show_more %} + + {% endif %} +
+{% endblock %} diff --git a/templates/create.twig b/templates/create.twig new file mode 100644 index 0000000..9d55233 --- /dev/null +++ b/templates/create.twig @@ -0,0 +1,33 @@ +{% extends 'master.twig' %} + +{% set title = 'Create Torrent' %} +{% set tracker_url = 'https://' ~ globals.site_info.host ~ '/announce/' ~ globals.auth_info.userInfo.passKey %} + +{% block content %} +
+
+

Creating a torrent

+

Here you can submit a torrent for tracking.

+

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.

+

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.

+

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

+
+ + {% if error is defined %} +
+
Error
+
+ {{ error }} +
+
+ {% endif %} + +
+
+ + + +
+
+
+{% endblock %} diff --git a/templates/history.twig b/templates/history.twig new file mode 100644 index 0000000..04583a9 --- /dev/null +++ b/templates/history.twig @@ -0,0 +1,8 @@ +{% extends 'master.twig' %} + +{% set title = 'Transfer History' %} + +{% block content %} + Transfer history should be listed here!
+ string({{ history_user_info.name|length }}) "{{ history_user_info.name }}" <-- this is fake var_dump output lol +{% endblock %} diff --git a/templates/http-error-500.html b/templates/http-error-500.html new file mode 100644 index 0000000..4903f04 --- /dev/null +++ b/templates/http-error-500.html @@ -0,0 +1,16 @@ + + + + + Error 500 + + + +

Error 500

+

Something went very wrong. Please report what you were doing to a developer.

+ + diff --git a/templates/http-error.twig b/templates/http-error.twig new file mode 100644 index 0000000..e7dc22d --- /dev/null +++ b/templates/http-error.twig @@ -0,0 +1,12 @@ +{% extends 'master.twig' %} + +{% set title = '#%03d %s'|format(http_code, http_text) %} + +{% block content %} +
+
Error
+
+ {{ title }} +
+
+{% endblock %} diff --git a/templates/index.twig b/templates/index.twig new file mode 100644 index 0000000..2d978d0 --- /dev/null +++ b/templates/index.twig @@ -0,0 +1,10 @@ +{% extends 'master.twig' %} + +{% block content %} +
+

Welcome to the {{ globals.site_info.name }}!

+

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

+

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.

+

Please enjoy responsibly!

+
+{% endblock %} diff --git a/templates/info.twig b/templates/info.twig new file mode 100644 index 0000000..9b50f85 --- /dev/null +++ b/templates/info.twig @@ -0,0 +1,112 @@ +{% extends 'master.twig' %} + +{% set title = torrent_info.name %} + +{% block content %} +
+
+

{{ torrent_info.name }}

+ +
+ +
+ + + {% if torrent_info.isApproved and globals.auth_info.isLoggedIn and globals.auth_info.userInfo.canApproveTorrents %} +
+
Approved on
+
+
+ {% endif %} + +
+
Total size
+
{{ torrent_total_size|format_filesize }} ({{ torrent_total_size|number_format }} bytes)
+
+ +
+
Uploading
+
{{ torrent_complete_peers|number_format }}
+
+ +
+
Downloading
+
{{ torrent_incomplete_peers|number_format }}
+
+ + {% if torrent_info.isPrivate %} +
+
Visibility
+
Private
+
+ {% endif %} +
+ + {% if torrent_user is not null %} +
+
Submitted by
+
+ +
+ {% endif %} + + {% if not torrent_info.isApproved and globals.auth_info.isLoggedIn and globals.auth_info.userInfo.canApproveTorrents %} +
+
This torrent is pending approval.
+ APPROVE + DENY +
+ {% endif %} + + {% if torrent_info.hasComment %} +
+
Description
+
{{ torrent_info.comment }}
+
+ {% endif %} +
+ +{% endblock %} diff --git a/templates/macros.twig b/templates/macros.twig new file mode 100644 index 0000000..fed34ef --- /dev/null +++ b/templates/macros.twig @@ -0,0 +1,34 @@ +{% macro torrent_listing_entry(torrent, class_name) %} + {% set torrent_info = torrent.info %} + {% set submitter_info = torrent.user %} + +
+
+ + + {% if submitter_info is not null %} + + {% endif %} +
+ + +
+
{{ torrent.complete_peers|number_format }}
+
{{ torrent.incomplete_peers|number_format }}
+
+ + {% if globals.auth_info.isLoggedIn %} +
+ {% if not torrent_info.isApproved and globals.auth_info.userInfo.canApproveTorrents %} + Approve + Deny + {% endif %} + + Download +
+ {% endif %} +
+{% endmacro %} diff --git a/templates/master.twig b/templates/master.twig new file mode 100644 index 0000000..90048be --- /dev/null +++ b/templates/master.twig @@ -0,0 +1,59 @@ + + + + + {% if title is defined %}{{ title }} :: {% endif %}{{ globals.site_info.name }} + + + + +
+ +
+ {% block content %} + meow + {% endblock %} +
+
+ +
None of the files shown here are actually hosted on this server. The administrator of this site ({{ globals.site_info.host }}) holds NO RESPONSIBILITY if these files are misused in any way and cannot be held responsible for what its users post, or any other actions of it.
+
+
+ + + diff --git a/templates/pending.twig b/templates/pending.twig new file mode 100644 index 0000000..4096326 --- /dev/null +++ b/templates/pending.twig @@ -0,0 +1,57 @@ +{% extends 'master.twig' %} +{% from 'macros.twig' import torrent_listing_entry %} + +{% set title = 'Pending Torrents' %} +{% set show_more = false %} +{% set last_id = 0 %} + +{% block content %} +
+
Pending Torrents
+
+ {% if torrents is empty %} +
There are no pending torrents!
+ {% else %} + {% for torrent in torrents %} + {{ torrent_listing_entry(torrent, 'downloads-item') }} + {% set last_id = torrent.info.id %} + {% endfor %} + {% endif %} +
+ + {% if show_more %} + + {% endif %} +
+ +{% endblock %} diff --git a/templates/profile.twig b/templates/profile.twig new file mode 100644 index 0000000..bbfface --- /dev/null +++ b/templates/profile.twig @@ -0,0 +1,48 @@ +{% extends 'master.twig' %} +{% from 'macros.twig' import torrent_listing_entry %} + +{% set title = profile_user.name %} + +{% set profile_ratio = profile_user.calculateRatio %} + +{% block content %} +
+
+
+ +
+ + + + + {% if profile_submissions is not empty %} +
+
Latest Submissions
+ + {% for torrent in profile_submissions %} + {{ torrent_listing_entry(torrent, 'profile-submission') }} + {% endfor %} + + +
+ {% endif %} + + +
+
Latest Transfers
+ + todo: keep track of this + + +
+
+{% endblock %} diff --git a/templates/settings.twig b/templates/settings.twig new file mode 100644 index 0000000..831d5f2 --- /dev/null +++ b/templates/settings.twig @@ -0,0 +1,7 @@ +{% extends 'master.twig' %} + +{% set title = 'Settings' %} + +{% block content %} + Provide option to reset pass key and shit here, maybe also a nuke tracker profile option but probably not. +{% endblock %} diff --git a/tools/migrate b/tools/migrate new file mode 100755 index 0000000..58b93b7 --- /dev/null +++ b/tools/migrate @@ -0,0 +1,34 @@ +#!/usr/bin/env php +createMigrationManager(); + + echo 'Preparing to run migrations...' . PHP_EOL; + $manager->init(); + + echo 'Creating migration repository...' . PHP_EOL; + $repo = $seria->createMigrationRepo(); + + echo 'Running migrations...' . PHP_EOL; + $completed = $manager->processMigrations($repo); + + if(empty($completed)) { + echo 'There were no migrations to run!' . PHP_EOL; + } else { + echo 'The following migrations have been completed:' . PHP_EOL; + foreach($completed as $migration) + echo ' - ' . $migration . PHP_EOL; + } + + echo PHP_EOL; +} finally { + unlink(SERIA_ROOT . '/.migrating'); +} diff --git a/tools/new-migration b/tools/new-migration new file mode 100755 index 0000000..7dbbb16 --- /dev/null +++ b/tools/new-migration @@ -0,0 +1,25 @@ +#!/usr/bin/env php +createMigrationRepo(); +if(!($repo instanceof FsDbMigrationRepo)) { + echo 'Migration repository type does not support creation of templates.' . PHP_EOL; + return; +} + +$baseName = implode(' ', array_slice($argv, 1)); +$manager = $seria->createMigrationManager(); + +try { + $names = $manager->createNames($baseName); +} catch(InvalidArgumentException $ex) { + echo $ex->getMessage() . PHP_EOL; + return; +} + +$repo->saveMigrationTemplate($names->name, $manager->template($names->className)); + +echo "Template for '{$names->className}' has been saved to {$names->name}.php." . PHP_EOL;