Rewrote the Tracker backend on top of Index.

This commit is contained in:
flash 2023-10-06 21:11:28 +00:00
parent 8b3051e442
commit bd17a0feb5
70 changed files with 4248 additions and 2211 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
/.debug
/config.php
/errors.log
/vendor

25
composer.json Normal file
View file

@ -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"
}
}

920
composer.lock generated Normal file
View file

@ -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"
}

View file

@ -0,0 +1,140 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class ExistingStructure_20230915_233145 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$hasTorrents = false;
$hasTorrentsFiles = false;
$hasTorrentsPeers = false;
$hasTorrentsPieces = false;
$hasUsers = false;
// check if the old tables exist
$tables = $conn->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";
');
}
}

6
phpstan.neon Normal file
View file

@ -0,0 +1,6 @@
parameters:
level: 5
paths:
- src
bootstrapFiles:
- seria.php

View file

@ -1,18 +0,0 @@
<?php
if($_SERVER['SCRIPT_FILENAME'] === __FILE__)
die('no');
?>
</div>
<footer class="footer">
<div class="copyright">
<span>Powered by <a href="//flashii.org/~flashii/seria">Seria</a> &copy; <a href="//flash.moe">Flashwave</a> 2021-<?=date('Y');?></span>
<span>/ <a href="//flashii.org/~flashii/seria/tree/00000000">00000000</a> # <a href="//flashii.org/~flashii/seria/commit/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx">xxxxxxx</a></span>
<?php if($sUserInfo->isFlash()): ?>
<span>/ SQL Queries: <?=number_format((int)$pdo->query('SHOW SESSION STATUS LIKE "Questions"')->fetchColumn(1));?></span>
<?php endif; ?>
</div>
<div class="disclaimer">None of the files shown here are actually hosted on this server. The administrator of this site (<?=$_SERVER['HTTP_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.</div>
</footer>
</div>
</body>
</html>

View file

@ -1,52 +0,0 @@
<?php
if($_SERVER['SCRIPT_FILENAME'] === __FILE__)
die('no');
if(empty($tPageTitle))
$tPageTitle = SERIA_FLASHII . ' Tracker';
else
$tPageTitle = $tPageTitle . ' :: ' . SERIA_FLASHII . ' Tracker';
$isLoggedIn = $sUserInfo->isLoggedIn();
?>
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title><?=$tPageTitle;?></title>
<link href="/seria.css" type="text/css" rel="stylesheet"/>
</head>
<body>
<div class="wrapper">
<nav class="header">
<?php
if($isLoggedIn):
$hTransferCount = $sUserInfo->getActiveTransferCounts();
$hTransferRatio = $sUserInfo->calculateRatio();
?>
<div class="header-stats">
<a href="/profile.php?name=<?=$sUserInfo->getName();?>"><?=$sUserInfo->getName();?></a>
&nbsp;&#183;&nbsp; <a href="/history.php">R:&nbsp; <span style="color: <?=seria_ratio_colour($hTransferRatio);?>;"><?=number_format($hTransferRatio, 3);?></span></a>
&nbsp;&#183;&nbsp; <a href="/history.php?filter=uploaded"><span style="color: green;">U:</span>&nbsp; <?=byte_symbol($sUserInfo->getBytesUploaded());?></a>
&nbsp;&#183;&nbsp; <a href="/history.php?filter=downloaded"><span style="color: red;">D:</span>&nbsp; <?=byte_symbol($sUserInfo->getBytesDownloaded());?></a>
&nbsp;&#183;&nbsp; <a href="/history.php?filter=uploading"><span style="color: green;">&#8593;</span>&nbsp; <?=number_format($hTransferCount->user_uploading);?></a>
&nbsp;&#183;&nbsp; <a href="/history.php?filter=downloading"><span style="color: red;">&#8595;</span>&nbsp; <?=number_format($hTransferCount->user_downloading);?></a>
</div>
<?php endif; ?>
<div class="header-logo"><a href="/"><?=SERIA_FLASHII;?> Tracker</a></div>
<div class="header-menu">
<?php if(!$isLoggedIn): ?>
<a href="<?=SERIA_CAUTH_LOGIN;?>">Log in</a>
<?php else: ?>
<a href="/settings.php">Settings</a>
<?php endif; ?>
<a href="/available.php">Available Downloads</a>
<?php if($sUserInfo->canCreateTorrents()): ?>
<a href="/create.php">Create Torrent</a>
<?php endif; ?>
<?php if($sUserInfo->canApproveTorrents()): ?>
<a href="/pending.php">Pending Torrents</a>
<?php endif; ?>
</div>
</nav>
<div class="wrapper-body">

View file

@ -1,144 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
function announce_fail(string $reason): never {
die(bencode(['failure reason' => $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);

View file

@ -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;
}

157
public/assets/seria.js Normal file
View file

@ -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,
};
})();

View file

@ -1,62 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
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 = null;
if($aUserInfo !== null)
$aPageTitle = 'Downloads submitted by ' . $aUserInfo->getName();
else
$aPageTitle = 'Available Downloads';
$tPageTitle = $aPageTitle;
$aPageUrl = '/available.php?';
if($aUserInfo !== null)
$aPageUrl .= 'name=' . $aUserInfo->getName() . '&amp;';
$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 '<div class="downloads">';
echo '<div class="downloads-header">' . $aPageTitle . '</div>';
echo '<div class="downloads-listing">';
$aShowMore = false;
$aLastId = 0;
if(empty($aTorrents)) {
echo '<div class="downloads-nothing">Sorry, nothing.</div>';
} else {
foreach($aTorrents as $torrent) {
echo $torrent->toHTML($sUserInfo, 'downloads-item');
$aLastId = $torrent->getId();
}
$aShowMore = count($aTorrents) === $aTake;
}
echo '</div>';
if($aShowMore)
echo '<div class="downloads-more"><a href="' . $aPageUrl . 'start=' . $aLastId . '">View more</a></div>';
echo '</div>';
require_once __DIR__ . '/_footer.php';

View file

@ -1,88 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
if(!$sUserInfo->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 '<div class="create">';
echo '<div class="create-header">';
echo '<h2>Creating a torrent</h2>';
echo '<p>Here you can submit a torrent for tracking.</p>';
echo '<p>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.</p>';
echo '<p>Use <input type="text" readonly value="' . $cTrackerUrl . '" onfocus="this.select()" /> 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, <b>do not share it</b>.</p>';
echo '<p>After your torrent has been submitted it will be queued for approval and won\'t immediately be available.</p>';
echo '</div>';
if(!empty($cError)) {
echo '<div class="create-error">';
echo '<div class="create-error-icon"><img src="//static.flash.moe/images/silk/error.png" alt="Error" /></div>';
echo '<div class="create-error-text">';
echo htmlspecialchars($cError);
echo '</div>';
echo '</div>';
}
echo '<div class="create-form">';
echo '<form action="/create.php" method="post" enctype="multipart/form-data">';
echo '<input type="hidden" name="boob" value="' . $sVerification . '" />';
echo '<label class="create-form-file"><input type="file" name="torrent" /></label>';
echo '<input class="create-form-submit" type="submit" value="Submit!" />';
echo '</form>';
echo '</div>';
echo '</div>';
require_once __DIR__ . '/_footer.php';

View file

@ -1,37 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
$dTorrentId = (string)filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT);
header('Content-Type: text/plain; charset=us-ascii');
try {
$dTorrentInfo = SeriaTorrent::byId($pdo, $dTorrentId);
} catch(SeriaTorrentNotFoundException $ex) {
http_response_code(404);
die('Download not found.');
}
$dCanDownload = $dTorrentInfo->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);

View file

@ -1,27 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
if(!$sUserInfo->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<br/>';
var_dump($aUserInfo->getName());
require_once __DIR__ . '/_footer.php';

View file

@ -1,13 +1,25 @@
<?php
namespace Seria;
use Seria\Auth\ChatAuth;
use Seria\Users\UserInfo;
require_once __DIR__ . '/../seria.php';
require_once __DIR__ . '/_header.php';
$authToken = (string)filter_input(INPUT_COOKIE, 'msz_auth');
$authInfo = ChatAuth::attempt(SERIA_CAUTH_ENDPOINT, SERIA_MSZ_SECRET, $authToken);
echo '<div class="index">';
echo '<h2>Welcome to the ' . SERIA_FLASHII . ' Tracker!</h2>';
echo '<p>This tracker is provided as a central download repository for files relevant to the community.</p>';
echo '<p>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.</p>';
echo '<p>Please enjoy responsibly!</p>';
echo '</div>';
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();

View file

@ -1,154 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
$dTorrentId = (string)filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT);
try {
$dTorrentInfo = SeriaTorrent::byId($pdo, $dTorrentId);
} catch(SeriaTorrentNotFoundException $ex) {
http_response_code(404);
die('Download not found.');
}
$dCanDownload = $dTorrentInfo->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 '<div class="info">';
echo '<div class="info-header">';
echo '<h2>' . htmlspecialchars($dTorrentInfo->getName()) . '</h2>';
echo '<ul>';
echo '<li><a href="/download.php?id=' . $dTorrentInfo->getId() . '"><img src="//static.flash.moe/images/silk/link.png" alt="" /> Download</a></li>';
if($sUserInfo->canRecalculateInfoHash())
echo '<li><a href="/info.php?id=' . $dTorrentInfo->getId() . '&amp;action=recalculate-info-hash&amp;boob=' . $sVerification . '"><img src="//static.flash.moe/images/silk/calculator.png" alt="" /> Recalculate Info Hash</a></li>';
echo '</ul>';
echo '</div>';
echo '<div class="info-stats">';
echo '<div class="info-stats-item info-stats-submitted">';
echo '<div class="info-stats-item-title">Submitted on</div>';
echo '<div class="info-stats-item-value"><time datetime="' . date('c', $dTorrentInfo->getCreatedTime()) . '">' . date('Y-m-d H:i:s e', $dTorrentInfo->getCreatedTime()) . '</time></div>';
echo '</div>';
if($sUserInfo->canApproveTorrents() && $dTorrentInfo->isApproved()) {
echo '<div class="info-stats-item info-stats-approved">';
echo '<div class="info-stats-item-title">Approved on</div>';
echo '<div class="info-stats-item-value"><time datetime="' . date('c', $dTorrentInfo->getApprovedTime()) . '">' . date('Y-m-d H:i:s e', $dTorrentInfo->getApprovedTime()) . '</time></div>';
echo '</div>';
}
echo '<div class="info-stats-item info-stats-size" style="border-color: ' . seria_size_colour($dTorrentInfo->getSize()) . '">';
echo '<div class="info-stats-item-title">Total size</div>';
echo '<div class="info-stats-item-value">' . byte_symbol($dTorrentInfo->getSize()) . ' (' . number_format($dTorrentInfo->getSize()) . ' bytes)</div>';
echo '</div>';
echo '<div class="info-stats-item info-stats-uploading">';
echo '<div class="info-stats-item-title">Uploading</div>';
echo '<div class="info-stats-item-value">' . number_format($dTorrentInfo->getCompletePeers()) . '</div>';
echo '</div>';
echo '<div class="info-stats-item info-stats-downloading">';
echo '<div class="info-stats-item-title">Downloading</div>';
echo '<div class="info-stats-item-value">' . number_format($dTorrentInfo->getIncompletePeers()) . '</div>';
echo '</div>';
if($dTorrentInfo->isPrivate()) {
echo '<div class="info-stats-item info-stats-visibility">';
echo '<div class="info-stats-item-title">Visibility</div>';
echo '<div class="info-stats-item-value">Private</div>';
echo '</div>';
}
echo '</div>';
if($dTorrentInfo->hasUser()) {
try {
$dUserInfo = SeriaUser::byId($pdo, $dTorrentInfo->getUserId());
echo '<div class="info-user" style="--user-colour:' . (string)$dUserInfo->getColour() . '">';
echo '<div class="info-user-uploaded">Submitted by</div>';
echo '<div class="avatar info-user-avatar"><a href="/profile.php?name=' . $dUserInfo->getName() . '"><img src="' . sprintf(SERIA_AVATAR_FORMAT_RES, $dUserInfo->getId(), 40) . '" alt="" width="20" height="20" /></a></div>';
echo '<div class="info-user-name"><a href="/profile.php?name=' . $dUserInfo->getName() . '">' . $dUserInfo->getName() . '</a></div>';
echo '</div>';
} catch(SeriaUserNotFoundException $ex) {}
}
if(!$dTorrentInfo->isApproved() && $sUserInfo->canApproveTorrents()) {
echo '<div class="info-pending">';
echo '<div class="info-pending-text">This torrent is pending approval.</div>';
echo '<a class="info-pending-approve" href="/info.php?id=' . $dTorrentInfo->getId() . '&amp;action=approve&amp;boob=' . $sVerification . '">APPROVE</a>';
echo '<a class="info-pending-deny" href="/info.php?id=' . $dTorrentInfo->getId() . '&amp;action=deny&amp;boob=' . $sVerification . '">DENY</a>';
echo '</div>';
}
$dComment = $dTorrentInfo->getComment();
if(!empty($dComment)) {
echo '<div class="info-comment">';
echo '<div class="info-comment-header">Description</div>';
echo '<div class="info-comment-content"><pre>';
echo htmlspecialchars($dComment);
echo '</pre></div>';
echo '</div>';
}
echo '</div>';
require_once __DIR__ . '/_footer.php';

View file

@ -1,55 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
if(!$sUserInfo->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 '<div class="downloads">';
echo '<div class="downloads-header">Pending Torrents</div>';
echo '<div class="downloads-listing">';
$pShowMore = false;
$pLastId = 0;
if(empty($pTorrents)) {
echo '<div class="downloads-nothing">There are no pending torrents!</div>';
} else {
foreach($pTorrents as $torrent) {
echo $torrent->toHTML($sUserInfo, 'downloads-item', true, $sVerification);
$pLastId = $torrent->getId();
}
$pShowMore = count($pTorrents) === $pTake;
}
echo '</div>';
if($pShowMore)
echo '<div class="downloads-more"><a href="' . $pPageUrl . 'start=' . $pLastId . '">View more</a></div>';
echo '</div>';
require_once __DIR__ . '/_footer.php';

View file

@ -1,71 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
if(!$sUserInfo->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 '<div class="profile" style="--user-colour:' . (string)$pUserInfo->getColour() . '" id="p' . $pUserInfo->getId() . '">';
echo '<div class="profile-header" id="p' . $pUserInfo->getId() . 'h">';
echo '<div class="avatar profile-header-avatar"><img src="' . sprintf(SERIA_AVATAR_FORMAT_RES, $pUserInfo->getId(), 200) . '" alt="" width="100" height="100" /></div>';
echo '<div class="profile-header-info">';
echo '<div class="profile-header-info-name"><span>' . $pUserInfo->getName() . '</span></div>';
echo '<div class="profile-header-info-flashii"><a href="' . sprintf(SERIA_PROFILE_FORMAT, $pUserInfo->getId()) . '">View full profile on ' . SERIA_FLASHIINET . '</a></div>';
echo '</div>';
echo '</div>';
echo '<div class="profile-transfer" id="p' . $pUserInfo->getId() . 'r">';
echo '<a href="/history.php?name=' . $pUserInfo->getName() . '">Ratio <span style="color: ' . seria_ratio_colour($pTransferRatio) . ';">' . number_format($pTransferRatio, 3) . '</span></a>';
echo '<a href="/history.php?name=' . $pUserInfo->getName() . '&amp;filter=uploaded"><span style="color: green;">Uploaded</span> ' . byte_symbol($pUserInfo->getBytesUploaded()) . '</a>';
echo '<a href="/history.php?name=' . $pUserInfo->getName() . '&amp;filter=downloaded"><span style="color: red;">Downloaded</span> ' . byte_symbol($pUserInfo->getBytesDownloaded()) . '</a>';
echo '<a href="/history.php?name=' . $pUserInfo->getName() . '&amp;filter=uploading"><span style="color: green;">Seeding</span> ' . number_format($pTransferCount->user_uploading) . '</a>';
echo '<a href="/history.php?name=' . $pUserInfo->getName() . '&amp;filter=downloading"><span style="color: red;">Leeching</span> ' . number_format($pTransferCount->user_downloading) . '</a>';
echo '</div>';
$pSubmissions = $pUserInfo->getProfileSubmissions();
if(!empty($pSubmissions)) {
echo '<div class="profile-submissions" id="p' . $pUserInfo->getId() . 's">';
echo '<div class="profile-submissions-header">Latest Submissions</div>';
foreach($pSubmissions as $submission)
echo $submission->toHTML($sUserInfo, 'profile-submission', false);
echo '<div class="profile-submissions-full"><a href="/available.php?name=' . $pUserInfo->getName() . '">View all submissions</a></div>';
echo '</div>';
}
echo '<div class="profile-history" id="p' . $pUserInfo->getId() . 't">';
echo '<div class="profile-history-header">Latest Transfers</div>';
echo 'todo: keep track of this';
echo '<div class="profile-history-full"><a href="/history.php?name=' . $pUserInfo->getName() . '">View full transfer history</a></div>';
echo '</div>';
echo '</div>';
require_once __DIR__ . '/_footer.php';

View file

@ -1,15 +0,0 @@
<?php
require_once __DIR__ . '/../seria.php';
if(!$sUserInfo->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';

View file

@ -1,41 +0,0 @@
<!doctype html>
<?php
ini_set('display_errors', 'on');
error_reporting(-1);
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));
}
for($i = 0; $i <= 100; ++$i) {
$if = $i / 100;
printf('<span style="color: %2$s;">%1$01.2f %2$s</span><br/>', $if, seria_ratio_colour($if));
}

164
seria.php
View file

@ -1,157 +1,25 @@
<?php
define('SERIA_ROOT', __DIR__);
namespace Seria;
use Index\Environment;
use Index\Data\DbTools;
define('SERIA_STARTUP', microtime(true));
define('SERIA_ROOT', __DIR__);
define('SERIA_DEBUG', is_file(SERIA_ROOT . '/.debug'));
define('SERIA_SRC', SERIA_ROOT . '/src');
define('SERIA_ERRORS', SERIA_ROOT . '/errors.log');
define('SERIA_VERSION', '1.0');
define('SERIA_DIR_SOURCE', SERIA_ROOT . '/src');
define('SERIA_DIR_MIGRATIONS', SERIA_ROOT . '/database');
define('SERIA_DIR_TEMPLATES', SERIA_ROOT . '/templates');
ini_set('display_errors', 'on');
require_once SERIA_ROOT . '/vendor/autoload.php';
if(SERIA_DEBUG) {
set_error_handler(function($errno, $errstr, $errfile, $errline) {
if(!is_file(SERIA_ERRORS))
touch(SERIA_ERRORS);
file_put_contents(SERIA_ERRORS, sprintf("(%s) [%s:%s] %s\r\n", date('Y-m-d H:i:s'), $errfile, $errline, $errstr), FILE_APPEND);
}, -1);
} else error_reporting(0);
Environment::setDebug(SERIA_DEBUG);
mb_internal_encoding('utf-8');
date_default_timezone_set('utc');
require_once SERIA_ROOT . '/config.php';
try {
$pdo = new PDO(SERIA_PDO_DSN, SERIA_PDO_USER, SERIA_PDO_PASS, [
PDO::ATTR_CASE => 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);

43
src/Auth/AuthInfo.php Normal file
View file

@ -0,0 +1,43 @@
<?php
namespace Seria\Auth;
use Index\Colour\Colour;
use Seria\Users\UserInfo;
class AuthInfo {
private ?UserInfo $userInfo;
public function __construct() {
$this->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;
}
}

54
src/Auth/ChatAuth.php Normal file
View file

@ -0,0 +1,54 @@
<?php
namespace Seria\Auth;
use stdClass;
final class ChatAuth {
public static function attempt(string $endPoint, string $secret, string $cookie): object {
if(!empty($cookie)) {
$method = 'Misuzu';
$signature = sprintf('verify#%s#%s#%s', $method, $cookie, $_SERVER['REMOTE_ADDR']);
$signature = hash_hmac('sha256', $signature, $secret);
$login = curl_init($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' => $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;
}
}

60
src/Colours.php Normal file
View file

@ -0,0 +1,60 @@
<?php
namespace Seria;
use Index\XNumber;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
final class Colours {
public const RATIO_GOOD = 0x008000;
public const RATIO_WARN = 0xFFAA00;
public const RATIO_BAD = 0xFF0000;
public const FILE_UP_TO_1GB = 0xA0F5B8;
public const FILE_UP_TO_5GB = 0xBAE9C7;
public const FILE_UP_TO_10GB = 0xCCDDDD;
public const FILE_UP_TO_20GB = 0xCCA1F4;
public const FILE_UP_TO_50GB = 0xDB5FF1;
public const FILE_OVER_50GB = 0xEC32A4;
private static array $colourCache = [];
public static function forRatio(float $ratio): Colour {
$warning = self::cached(self::RATIO_WARN);
$ratio *= 2;
if($ratio > 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);
}
}

24
src/GitInfo.php Normal file
View file

@ -0,0 +1,24 @@
<?php
namespace Seria;
final class GitInfo {
public static function log(string $format, string $args = ''): string {
return trim(shell_exec(sprintf('git log --pretty="%s" %s -n1 HEAD', $format, $args)));
}
public static function hash(bool $long = false): string {
return self::log($long ? '%H' : '%h');
}
public static function branch(): string {
return trim(`git rev-parse --abbrev-ref HEAD`);
}
public static function tag(): string {
return trim(`git describe --abbrev=0 --tags`);
}
public static function version(): string {
return self::branch() === 'HEAD' ? self::tag() : self::hash();
}
}

22
src/HomeRoutes.php Normal file
View file

@ -0,0 +1,22 @@
<?php
namespace Seria;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Sasae\SasaeEnvironment;
class HomeRoutes extends RouteHandler {
public function __construct(
private ?SasaeEnvironment $templating
) {}
#[Route('GET', '/')]
public function getIndex() {
return $this->templating->render('index');
}
#[Route('GET', '/index.php')]
public function getIndexPHP($response): void {
$response->redirect('/', true);
}
}

49
src/RoutingContext.php Normal file
View file

@ -0,0 +1,49 @@
<?php
namespace Seria;
use Index\Http\HttpFx;
use Index\Http\HttpResponseBuilder;
use Index\Http\HttpRequest;
use Index\Routing\IRouter;
use Index\Routing\IRouteHandler;
class RoutingContext {
private HttpFx $router;
public function __construct() {
$this->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(
'<!doctype html><html><head><meta charset="utf-8"/><title>%1$03d %2$s</title></head><body><center><h1>%1$03d %2$s</h1></center><hr/><center>Seria</center></body></html>',
$code,
$message
));
}
public function register(IRouteHandler $handler): void {
$this->router->register($handler);
}
public function dispatch(?HttpRequest $request = null): void {
$this->router->dispatch($request);
}
}

117
src/SeriaContext.php Normal file
View file

@ -0,0 +1,117 @@
<?php
namespace Seria;
use InvalidArgumentException;
use Index\Environment;
use Index\Data\IDbTransactions;
use Index\Data\Migration\IDbMigrationRepo;
use Index\Data\Migration\DbMigrationManager;
use Index\Data\Migration\FsDbMigrationRepo;
use Index\Security\CSRFP;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Torrents\TorrentsContext;
use Seria\Users\UsersContext;
final class SeriaContext {
private IDbTransactions $dbConn;
private ?SasaeEnvironment $templating = null;
private AuthInfo $authInfo;
private SiteInfo $siteInfo;
private CSRFP $csrfp;
private TorrentsContext $torrentsCtx;
private UsersContext $usersCtx;
public function __construct(IDbTransactions $dbConn) {
$this->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;
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Seria;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Seria\Torrents\TorrentPeers;
final class SeriaSasaeExtension extends AbstractExtension {
private TorrentPeers $peers;
public function __construct(
private SeriaContext $ctx
) {
$this->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;
}
}

34
src/SiteInfo.php Normal file
View file

@ -0,0 +1,34 @@
<?php
namespace Seria;
use Seria\Users\UserInfo;
// this should pull from some db config or something at some point
class SiteInfo {
public function getName(): string {
return SERIA_FLASHII . ' Tracker';
}
public function getHost(): string {
return (string)filter_input(INPUT_SERVER, 'HTTP_HOST');
}
public function getMainSiteName(): string {
return SERIA_FLASHIINET;
}
public function getProfileUrl(UserInfo|string $userInfo): string {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->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);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace Seria\Torrents;
use Index\Serialisation\IBencodeSerialisable;
class AnnounceEmpty implements IBencodeSerialisable {
public function __construct(
private bool $compactPeers
) {}
public function bencodeSerialise(): mixed {
return [
'interval' => 0,
'min interval' => 0,
'complete' => 0,
'incomplete' => 0,
'peers' => $this->compactPeers ? '' : [],
];
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Seria\Torrents;
use Index\Serialisation\IBencodeSerialisable;
class AnnounceFailure implements IBencodeSerialisable {
public function __construct(private string $reason) {}
public function bencodeSerialise(): mixed {
return ['failure reason' => $this->reason];
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace Seria\Torrents;
use Index\Serialisation\IBencodeSerialisable;
class AnnounceInfo implements IBencodeSerialisable {
// todo: keep these in a field in ser_torrents so we don't need this slightly filthy hack anymore
// also useful for ipv6 peer support
private int $completePeers = 0;
private int $incompletePeers = 0;
public function __construct(
private array $peers,
private int $interval,
private int $minInterval,
private bool $compactPeers,
private bool $noPeerIds
) {}
private function createCompactPeersList(): string {
$peers = '';
foreach($this->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
];
}
}

View file

@ -0,0 +1,144 @@
<?php
namespace Seria\Torrents;
use RuntimeException;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Index\Serialisation\Bencode;
use Seria\Users\UsersContext;
class AnnounceRouting extends RouteHandler {
public const INTERVAL = 1800;
public const INTERVAL_MIN = 30;
public function __construct(
private TorrentsContext $torrentsCtx,
private UsersContext $usersCtx
) {}
#[Route('GET', '/announce')]
#[Route('GET', '/announce.php')]
#[Route('GET', '/announce/:key')]
#[Route('GET', '/announce.php/:key')]
public function getAnnounce($response, $request, string $key = '') {
if(strlen(inet_pton($_SERVER['REMOTE_ADDR'])) !== 4)
return new AnnounceFailure('Tracker is only supported over IPv4, please reset your DNS cache.');
$torrents = $this->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
);
}
}

View file

@ -0,0 +1,205 @@
<?php
namespace Seria\Torrents;
use Exception;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbTools;
use Index\Data\IDbTransactions;
use Index\Serialisation\Bencode;
use Seria\Users\UserInfo;
class TorrentBuilder {
private ?string $userId = null;
private string $name = '';
private int $created;
private int $pieceLength = 0;
private bool $isPrivate = false;
private string $comment = '';
private array $files = [];
private array $pieces = [];
public function __construct() {
$this->created = time();
}
public function setUser(?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;
}
}

View file

@ -0,0 +1,102 @@
<?php
namespace Seria\Torrents;
use Exception;
use RuntimeException;
use Index\Data\IDbTransactions;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Index\Security\CSRFP;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Users\UsersContext;
class TorrentCreateRouting extends RouteHandler {
public function __construct(
private IDbTransactions $dbConn,
private AuthInfo $authInfo,
private TorrentsContext $torrentsCtx,
private CSRFP $csrfp,
private ?SasaeEnvironment $templating
) {}
#[Route('/create')]
public function checkAccess() {
if(!$this->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);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace Seria\Torrents;
use Index\Data\IDbResult;
use Index\Serialisation\IBencodeSerialisable;
readonly class TorrentFileInfo implements IBencodeSerialisable {
private string $id;
private string $torrentId;
private int $length;
private string $path;
public function __construct(IDbResult $result) {
$this->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),
];
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace Seria\Torrents;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class TorrentFiles {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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;
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace Seria\Torrents;
use Index\Data\IDbResult;
readonly class TorrentInfo {
private string $id;
private ?string $userId;
private string $infoHash;
private int $active;
private string $name;
private int $created;
private ?int $approved;
private int $pieceLength;
private int $private;
private string $comment;
public function __construct(IDbResult $result) {
$this->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;
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace Seria\Torrents;
use RuntimeException;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Index\Security\CSRFP;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Users\UsersContext;
class TorrentInfoRouting extends RouteHandler {
private ?TorrentInfo $torrentInfo = null;
public function __construct(
private AuthInfo $authInfo,
private TorrentsContext $torrentsCtx,
private UsersContext $usersCtx,
private CSRFP $csrfp,
private ?SasaeEnvironment $templating
) {}
#[Route('GET', '/download/:id')]
public function getDownload($response, $request, string $torrentId) {
try {
$torrentInfo = $this->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);
}
}

View file

@ -0,0 +1,136 @@
<?php
namespace Seria\Torrents;
use RuntimeException;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Users\UsersContext;
class TorrentListRouting extends RouteHandler {
public function __construct(
private AuthInfo $authInfo,
private TorrentsContext $torrentsCtx,
private UsersContext $usersCtx,
private ?SasaeEnvironment $templating
) {}
#[Route('GET', '/available')]
public function getAvailable($response, $request) {
$users = $this->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);
}
}

View file

@ -0,0 +1,108 @@
<?php
namespace Seria\Torrents;
use Index\Data\IDbResult;
use Seria\Users\UserInfo;
readonly class TorrentPeerInfo {
private string $id;
private string $torrentId;
private ?string $userId;
private string $address;
private int $port;
private int $updated;
private int $expires;
private string $agent;
private string $key;
private int $bytesUploaded;
private int $bytesDownloaded;
private int $bytesLeft;
public function __construct(IDbResult $result) {
$this->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);
}
}

View file

@ -0,0 +1,142 @@
<?php
namespace Seria\Torrents;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Seria\Users\UserInfo;
class TorrentPeers {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Seria\Torrents;
use Index\Data\IDbResult;
readonly class TorrentPieceInfo {
private string $id;
private string $torrentId;
private string $hash;
public function __construct(IDbResult $result) {
$this->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;
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace Seria\Torrents;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class TorrentPieces {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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();
}
}

161
src/Torrents/Torrents.php Normal file
View file

@ -0,0 +1,161 @@
<?php
namespace Seria\Torrents;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Seria\Users\UserInfo;
class Torrents {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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();
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Seria\Torrents;
use Index\Data\IDbConnection;
use Index\Serialisation\Bencode;
use Seria\GitInfo;
use Seria\Users\UserInfo;
class TorrentsContext {
private Torrents $torrents;
private TorrentFiles $files;
private TorrentPeers $peers;
private TorrentPieces $pieces;
public function __construct(IDbConnection $dbConn) {
$this->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);
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Seria\Users;
use RuntimeException;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
use Seria\Torrents\TorrentsContext;
use Seria\Torrents\TorrentInfo;
use Seria\Torrents\TorrentPeerInfo;
class ProfileRoutes extends RouteHandler {
public function __construct(
private AuthInfo $authInfo,
private TorrentsContext $torrentsCtx,
private UsersContext $usersCtx,
private ?SasaeEnvironment $templating
) {}
#[Route('GET', '/profile/:name')]
public function getProfile($response, $request, string $name) {
if(!$this->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);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace Seria\Users;
use Index\Routing\Route;
use Index\Routing\RouteHandler;
use Sasae\SasaeEnvironment;
use Seria\Auth\AuthInfo;
class SettingsRoutes extends RouteHandler {
public function __construct(
private AuthInfo $authInfo,
private ?SasaeEnvironment $templating
) {}
#[Route('GET', '/settings')]
public function getIndex($response) {
if(!$this->authInfo->isLoggedIn())
return 403;
return $this->templating->render('settings');
}
#[Route('GET', '/settings.php')]
public function getSettingsPHP($response): void {
$response->redirect('/settings', true);
}
}

101
src/Users/UserInfo.php Normal file
View file

@ -0,0 +1,101 @@
<?php
namespace Seria\Users;
use RuntimeException;
use Index\Colour\Colour;
use Index\Data\IDbResult;
use Seria\Colours;
readonly class UserInfo {
private string $id;
private string $name;
private ?int $colour;
private int $rank;
private int $perms;
private ?string $passKey;
private int $bytesDownloaded;
private int $bytesUploaded;
public function __construct(IDbResult $result) {
$this->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;
}
}

127
src/Users/Users.php Normal file
View file

@ -0,0 +1,127 @@
<?php
namespace Seria\Users;
use InvalidArgumentException;
use RuntimeException;
use Index\DateTime;
use Index\XString;
use Index\Colour\Colour;
use Index\Data\DbStatementCache;
use Index\Data\DbTools;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
class Users {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->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();
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace Seria\Users;
use Index\Data\IDbConnection;
class UsersContext {
private Users $users;
public function __construct(IDbConnection $dbConn) {
$this->users = new Users($dbConn);
}
public function getUsers(): Users {
return $this->users;
}
}

View file

@ -1,51 +0,0 @@
<?php
class SeriaAnnounceResponse implements BEncodeSerializable {
private int $interval;
private int $minInterval;
private ?SeriaTorrent $torrent;
private bool $includePeerId;
private bool $compactPeers;
private array $peers = [];
public function __construct(
int $interval = 0,
int $minInterval = 0,
SeriaTorrent $torrent = null,
bool $includePeerId = false,
bool $compactPeers = false
) {
$this->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;
}
}

View file

@ -1,146 +0,0 @@
<?php
interface BEncodeSerializable {
function bencodeSerialize(): mixed;
}
// todo: if $depth < 1 return unencoded, or just get mad i dunno yet
function bdecode(mixed $input, bool $dictAsObject = false, int $depth = 512): mixed {
if(is_string($input)) {
$file = fopen('php://temp', 'rb+');
fwrite($file, $input);
fseek($file, 0, SEEK_SET);
$output = bdecode($file, $dictAsObject, $depth);
fclose($file);
return $output;
} elseif(!is_resource($input))
return null;
$char = fgetc($input);
if($char === false)
die('bdecode: unexpected eof');
switch($char) {
case 'i':
$number = '';
for(;;) {
$char = fgetc($input);
if($char === false)
die('bdecode: integer unexpected eof');
if($char === 'e')
break;
if($char === '-' && $number !== '')
die('bdecode: integer unexpected minus');
if($char === '0' && $number === '-')
die('bdecode: integer negative zero');
if($char === '0' && $number === '0')
die('bdecode: integer double zero');
if(!ctype_digit($char))
die('bdecode: integer unexpected char');
$number .= $char;
}
return intval($number);
case 'l':
$list = [];
for(;;) {
$char = fgetc($input);
if($char === false)
die('bdecode: list unexpected eof');
if($char === 'e')
break;
fseek($input, -1, SEEK_CUR);
$list[] = bdecode($input, $dictAsObject, $depth - 1);
}
return $list;
case 'd':
$dict = [];
for(;;) {
$char = fgetc($input);
if($char === false)
die('bdecode: dict unexpected eof');
if($char === 'e')
break;
if(!ctype_digit($char))
die('bdecode: dict expecting string');
fseek($input, -1, SEEK_CUR);
$dict[bdecode($input)] = bdecode($input, $dictAsObject, $depth - 1);
}
if($dictAsObject)
$dict = (object)$dict;
return $dict;
default:
if(!ctype_digit($char))
die('bdecode: unexpected char');
$length = $char;
for(;;) {
$char = fgetc($input);
if($char === false)
die('bdecode: string unexpected eof');
if($char === ':')
break;
if($char === '0' && $length === '0')
die('bdecode: string double zero');
if(!ctype_digit($char))
die('bdecode: string unexpected char');
$length .= $char;
}
return fread($input, intval($length));
}
return null;
}
function bencode(mixed $input): string {
switch(gettype($input)) {
case 'string':
return sprintf('%d:%s', strlen($input), $input);
case 'integer':
return sprintf('i%de', $input);
case 'array':
if(array_is_list($input)) {
$output = 'l';
foreach($input as $item)
$output .= bencode($item);
} else {
$output = 'd';
foreach($input as $key => $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 '';
}
}

View file

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

View file

@ -1,217 +0,0 @@
<?php
class SeriaUserNotFoundException extends RuntimeException {}
class SeriaUserExtremelyNotFoundException extends SeriaUserNotFoundException {}
class SeriaUserColour {
public const INHERIT = 0x40000000;
private int $raw;
public function __construct(int $raw) {
$this->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;
}
}

31
templates/available.twig Normal file
View file

@ -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 %}
<div class="downloads">
<div class="downloads-header">{{ title }}</div>
<div class="downloads-listing">
{% if torrents is empty %}
<div class="downloads-nothing">Sorry, nothing.</div>
{% else %}
{% for torrent in torrents %}
{{ torrent_listing_entry(torrent, 'downloads-item') }}
{% set last_id = torrent.info.id %}
{% endfor %}
{% endif %}
</div>
{% if show_more %}
<div class="downloads-more"><a href="{{ page_url }}start={{ last_id }}">View more</a></div>
{% endif %}
</div>
{% endblock %}

33
templates/create.twig Normal file
View file

@ -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 %}
<div class="create">
<div class="create-header">
<h2>Creating a torrent</h2>
<p>Here you can submit a torrent for tracking.</p>
<p>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.</p>
<p>Use <input type="text" readonly value="{{ tracker_url }}" onfocus="this.select()"> 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, <b>do not share it</b>.</p>
<p>After your torrent has been submitted it will be queued for approval and won't immediately be available.</p>
</div>
{% if error is defined %}
<div class="create-error">
<div class="create-error-icon"><img src="//static.flash.moe/images/silk/error.png" alt="Error"></div>
<div class="create-error-text">
{{ error }}
</div>
</div>
{% endif %}
<div class="create-form">
<form action="/create" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrfp" value="{{ csrfp_token() }}">
<label class="create-form-file"><input type="file" name="torrent"></label>
<input class="create-form-submit" type="submit" value="Submit!">
</form>
</div>
</div>
{% endblock %}

8
templates/history.twig Normal file
View file

@ -0,0 +1,8 @@
{% extends 'master.twig' %}
{% set title = 'Transfer History' %}
{% block content %}
Transfer history should be listed here!<br>
string({{ history_user_info.name|length }}) "{{ history_user_info.name }}" &lt;-- this is fake var_dump output lol
{% endblock %}

View file

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Error 500</title>
<style type="text/css">
body {
font: 12px/20px Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
}
</style>
</head>
<body>
<h1>Error 500</h1>
<p>Something went very wrong. Please report what you were doing to a developer.</p>
</body>
</html>

12
templates/http-error.twig Normal file
View file

@ -0,0 +1,12 @@
{% extends 'master.twig' %}
{% set title = '#%03d %s'|format(http_code, http_text) %}
{% block content %}
<div class="http-error">
<div class="http-error-icon"><img src="//static.flash.moe/images/silk/error.png" alt="Error"></div>
<div class="http-error-text">
{{ title }}
</div>
</div>
{% endblock %}

10
templates/index.twig Normal file
View file

@ -0,0 +1,10 @@
{% extends 'master.twig' %}
{% block content %}
<div class="index">
<h2>Welcome to the {{ globals.site_info.name }}!</h2>
<p>This tracker is provided as a central download repository for files relevant to the community.</p>
<p>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.</p>
<p>Please enjoy responsibly!</p>
</div>
{% endblock %}

112
templates/info.twig Normal file
View file

@ -0,0 +1,112 @@
{% extends 'master.twig' %}
{% set title = torrent_info.name %}
{% block content %}
<div class="info">
<div class="info-header">
<h2>{{ torrent_info.name }}</h2>
<ul>
<li><a href="/download/{{ torrent_info.id }}"><img src="//static.flash.moe/images/silk/link.png" alt=""> Download</a></li>
{% if globals.auth_info.isLoggedIn and globals.auth_info.userInfo.canRecalculateInfoHash %}
<li><a href="javascript:;" class="js-info-hash-recalc"><img src="//static.flash.moe/images/silk/calculator.png" alt=""> Recalculate Info Hash</a></li>
{% endif %}
</ul>
</div>
<div class="info-stats">
<div class="info-stats-item info-stats-submitted">
<div class="info-stats-item-title">Submitted on</div>
<div class="info-stats-item-value"><time datetime="{{ torrent_info.createdTime|date('c') }}">{{ torrent_info.createdTime|date('Y-m-d H:i:s e') }}</time></div>
</div>
{% if torrent_info.isApproved and globals.auth_info.isLoggedIn and globals.auth_info.userInfo.canApproveTorrents %}
<div class="info-stats-item info-stats-approved">
<div class="info-stats-item-title">Approved on</div>
<div class="info-stats-item-value"><time datetime="{{ torrent_info.approvedTime|date('c') }}">{{ torrent_info.approvedTime|date('Y-m-d H:i:s e') }}</time></div>
</div>
{% endif %}
<div class="info-stats-item info-stats-size" style="border-color: {{ seria_filesize_colour(torrent_total_size) }}">
<div class="info-stats-item-title">Total size</div>
<div class="info-stats-item-value">{{ torrent_total_size|format_filesize }} ({{ torrent_total_size|number_format }} bytes)</div>
</div>
<div class="info-stats-item info-stats-uploading">
<div class="info-stats-item-title">Uploading</div>
<div class="info-stats-item-value">{{ torrent_complete_peers|number_format }}</div>
</div>
<div class="info-stats-item info-stats-downloading">
<div class="info-stats-item-title">Downloading</div>
<div class="info-stats-item-value">{{ torrent_incomplete_peers|number_format }}</div>
</div>
{% if torrent_info.isPrivate %}
<div class="info-stats-item info-stats-visibility">
<div class="info-stats-item-title">Visibility</div>
<div class="info-stats-item-value">Private</div>
</div>
{% endif %}
</div>
{% if torrent_user is not null %}
<div class="info-user" style="--user-colour: {{ torrent_user.colour }}">
<div class="info-user-uploaded">Submitted by</div>
<div class="avatar info-user-avatar"><a href="/profile/{{ torrent_user.name }}"><img src="{{ globals.site_info.avatarUrl(torrent_user, 40) }}" alt="" width="20" height="20"></a></div>
<div class="info-user-name"><a href="/profile/{{ torrent_user.name }}">{{ torrent_user.name }}</a></div>
</div>
{% endif %}
{% if not torrent_info.isApproved and globals.auth_info.isLoggedIn and globals.auth_info.userInfo.canApproveTorrents %}
<div class="info-pending">
<div class="info-pending-text">This torrent is pending approval.</div>
<a class="info-pending-approve js-info-approve" href="javascript:;">APPROVE</a>
<a class="info-pending-deny js-info-deny" href="javascript:;">DENY</a>
</div>
{% endif %}
{% if torrent_info.hasComment %}
<div class="info-comment">
<div class="info-comment-header">Description</div>
<div class="info-comment-content"><pre>{{ torrent_info.comment }}</pre></div>
</div>
{% endif %}
</div>
<script>
(function() {
const dlId = '{{ torrent_info.id }}';
const jsInfoHashRecalc = document.querySelector('.js-info-hash-recalc');
if(jsInfoHashRecalc)
jsInfoHashRecalc.onclick = () => seria.dls.rehash(dlId).then(result => {
if(result.hash)
alert('Recalculated info hash is: ' + result.hash);
else
alert('Something went wrong: ' + result);
});
const jsInfoApprove = document.querySelector('.js-info-approve');
if(jsInfoApprove)
jsInfoApprove.onclick = () => seria.dls.approve(dlId).then(result => {
if(result !== '') {
alert('Something went wrong: ' + result);
return;
}
location.reload();
});
const jsInfoDeny = document.querySelector('.js-info-deny');
if(jsInfoDeny)
jsInfoDeny.onclick = () => seria.dls.deny(dlId).then(result => {
if(result !== '') {
alert('Something went wrong: ' + result);
return;
}
location.assign('/pending');
});
})();
</script>
{% endblock %}

34
templates/macros.twig Normal file
View file

@ -0,0 +1,34 @@
{% macro torrent_listing_entry(torrent, class_name) %}
{% set torrent_info = torrent.info %}
{% set submitter_info = torrent.user %}
<div class="tdl {{ class_name }}" id="tdl{{ torrent_info.id }}">
<div class="tdl-details">
<div class="tdl-details-name"><a href="/info/{{ torrent_info.id }}">{{ torrent_info.name }}</a></div>
{% if submitter_info is not null %}
<div class="tdl-user" style="--user-colour: {{ submitter_info.colour }}">
<div class="avatar tdl-user-avatar"><a href="/profile/{{ submitter_info.name }}"><img src="{{ globals.site_info.avatarUrl(submitter_info, 40) }}" alt="" width="20" height="20"></a></div>
<div class="tdl-user-name"><a href="/profile/{{ submitter_info.name }}">{{ submitter_info.name }}</a></div>
</div>
{% endif %}
</div>
<div class="tdl-stats">
<div class="tdl-stats-uploading" title="Uploading"><div class="arrow">&#8593;</div><div class="number">{{ torrent.complete_peers|number_format }}</div></div>
<div class="tdl-stats-downloading" title="Downloading"><div class="arrow">&#8595;</div><div class="number">{{ torrent.incomplete_peers|number_format }}</div></div>
</div>
{% if globals.auth_info.isLoggedIn %}
<div class="tdl-actions">
{% if not torrent_info.isApproved and globals.auth_info.userInfo.canApproveTorrents %}
<a href="javascript:;" title="Approve" class="js-listing-approve" data-dlid="{{ torrent_info.id }}"><img src="//static.flash.moe/images/silk/tick.png" alt="Approve"></a>
<a href="javascript:;" title="Deny" class="js-listing-deny" data-dlid="{{ torrent_info.id }}"><img src="//static.flash.moe/images/silk/cross.png" alt="Deny"></a>
{% endif %}
<a href="/download/{{ torrent_info.id }}" title="Download"><img src="//static.flash.moe/images/silk/link.png" alt="Download"></a>
</div>
{% endif %}
</div>
{% endmacro %}

59
templates/master.twig Normal file
View file

@ -0,0 +1,59 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>{% if title is defined %}{{ title }} :: {% endif %}{{ globals.site_info.name }}</title>
<link href="/assets/seria.css" type="text/css" rel="stylesheet">
<meta name="csrfp-token" content="{{ csrfp_token() }}">
</head>
<body>
<div class="wrapper">
<nav class="header">
{% if globals.auth_info.isLoggedIn %}
{% set user_info = globals.auth_info.userInfo %}
{% set user_ratio = user_info.calculateRatio %}
<div class="header-stats">
<a href="/profile/{{ globals.auth_info.userName }}">{{ globals.auth_info.userName }}</a>
&nbsp;&#183;&nbsp; <a href="/profile/{{ globals.auth_info.userName }}/history">R:&nbsp; <span style="color: {{ seria_ratio_colour(user_ratio) }};">{{ user_ratio|number_format(3) }}</span></a>
&nbsp;&#183;&nbsp; <a href="/profile/{{ globals.auth_info.userName }}/history?filter=uploaded"><span style="color: green;">U:</span>&nbsp; {{ user_info.bytesUploaded|format_filesize }}</a>
&nbsp;&#183;&nbsp; <a href="/profile/{{ globals.auth_info.userName }}/history?filter=downloaded"><span style="color: red;">D:</span>&nbsp; {{ user_info.bytesDownloaded|format_filesize }}</a>
&nbsp;&#183;&nbsp; <a href="/profile/{{ globals.auth_info.userName }}/history?filter=uploading"><span style="color: green;">&#8593;</span>&nbsp; {{ seria_count_user_uploading(user_info)|number_format }}</a>
&nbsp;&#183;&nbsp; <a href="/profile/{{ globals.auth_info.userName }}/history?filter=downloading"><span style="color: red;">&#8595;</span>&nbsp; {{ seria_count_user_downloading(user_info)|number_format }}</a>
</div>
{% endif %}
<div class="header-logo"><a href="/">{{ globals.site_info.name }}</a></div>
<div class="header-menu">
{% set header_menu = seria_header_menu() %}
{% for link in header_menu %}
<a href="{{ link.url }}">{{ link.text }}</a>
{% endfor %}
</div>
</nav>
<div class="wrapper-body">
{% block content %}
meow
{% endblock %}
</div>
<footer class="footer">
<div class="copyright">
<span>Powered by <a href="//git.flash.moe/flashii/seria">Seria</a> &copy; <a href="//flash.moe">flashwave</a> 2021-{{ 'now'|date('Y') }}</span>
<span>/
{% set git_branch = git_branch() %}
{% if git_branch != 'HEAD' %}
<a href="https://git.flash.moe/flashii/seria/src/branch/{{ git_branch }}" target="_blank" rel="noreferrer noopener">{{ git_branch }}</a>
{% else %}
{% set git_tag = git_tag() %}
<a href="https://git.flash.moe/flashii/seria/src/tag/{{ git_tag }}" target="_blank" rel="noreferrer noopener">{{ git_tag }}</a>
{% endif %}
# <a href="https://git.flash.moe/flashii/seria/commit/{{ git_commit_hash(true) }}" target="_blank" rel="noreferrer noopener">{{ git_commit_hash() }}</a>
</span>
{% if globals.display_timings_info %}
<span>/ SQL Queries: {{ seria_db_query_count()|number_format }}</span>
{% endif %}
</div>
<div class="disclaimer">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.</div>
</footer>
</div>
<script src="/assets/seria.js"></script>
</body>
</html>

57
templates/pending.twig Normal file
View file

@ -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 %}
<div class="downloads">
<div class="downloads-header">Pending Torrents</div>
<div class="downloads-listing">
{% if torrents is empty %}
<div class="downloads-nothing">There are no pending torrents!</div>
{% else %}
{% for torrent in torrents %}
{{ torrent_listing_entry(torrent, 'downloads-item') }}
{% set last_id = torrent.info.id %}
{% endfor %}
{% endif %}
</div>
{% if show_more %}
<div class="downloads-more"><a href="{{ page_url }}start={{ last_id }}">View more</a></div>
{% endif %}
</div>
<script>
(function() {
const nukeListing = (dlId) => document.getElementById(`tdl${dlId}`)?.remove();
const jsPendingApprove = document.querySelectorAll('.js-listing-approve');
for(const elem of jsPendingApprove)
(function(elem) {
elem.onclick = () => seria.dls.approve(elem.dataset.dlid).then(result => {
if(result !== '') {
alert('Something went wrong: ' + result);
return;
}
nukeListing(elem.dataset.dlid);
});
})(elem);
const jsPendingDeny = document.querySelectorAll('.js-listing-deny');
for(const elem of jsPendingDeny)
(function(elem) {
elem.onclick = () => seria.dls.deny(elem.dataset.dlid).then(result => {
if(result !== '') {
alert('Something went wrong: ' + result);
return;
}
nukeListing(elem.dataset.dlid);
});
})(elem);
})();
</script>
{% endblock %}

48
templates/profile.twig Normal file
View file

@ -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 %}
<div class="profile" style="--user-colour: {{ profile_user.colour }}" id="p{{ profile_user.id }}">
<div class="profile-header" id="p{{ profile_user.id }}h">
<div class="avatar profile-header-avatar"><img src="{{ globals.site_info.avatarUrl(profile_user, 200) }}" alt="" width="100" height="100"></div>
<div class="profile-header-info">
<div class="profile-header-info-name"><span>{{ profile_user.name }}</span></div>
<div class="profile-header-info-flashii"><a href="{{ globals.site_info.profileUrl(profile_user) }}">View full profile on {{ globals.site_info.mainSiteName }}</a></div>
</div>
</div>
<div class="profile-transfer" id="p{{ profile_user.id }}r">
<a href="/profile/{{ profile_user.name }}/history">Ratio <span style="color: {{ seria_ratio_colour(profile_ratio) }};">{{ profile_ratio|number_format(3) }}</span></a>
<a href="/profile/{{ profile_user.name }}/history?filter=uploaded"><span style="color: green;">Uploaded</span> {{ profile_user.bytesUploaded|format_filesize }}</a>
<a href="/profile/{{ profile_user.name }}/history?filter=downloaded"><span style="color: red;">Downloaded</span> {{ profile_user.bytesDownloaded|format_filesize }}</a>
<a href="/profile/{{ profile_user.name }}/history?filter=uploading"><span style="color: green;">Seeding</span> {{ profile_uploading|number_format }}</a>
<a href="/profile/{{ profile_user.name }}/history?filter=downloading"><span style="color: red;">Leeching</span> {{ profile_downloading|number_format }}</a>
</div>
{% if profile_submissions is not empty %}
<div class="profile-submissions" id="p{{ profile_user.id }}s">
<div class="profile-submissions-header">Latest Submissions</div>
{% for torrent in profile_submissions %}
{{ torrent_listing_entry(torrent, 'profile-submission') }}
{% endfor %}
<div class="profile-submissions-full"><a href="/available?name={{ profile_user.name }}">View all submissions</a></div>
</div>
{% endif %}
<div class="profile-history" id="p{{ profile_user.id }}t">
<div class="profile-history-header">Latest Transfers</div>
todo: keep track of this
<div class="profile-history-full"><a href="/profile/{{ profile_user.name }}/history">View full transfer history</a></div>
</div>
</div>
{% endblock %}

7
templates/settings.twig Normal file
View file

@ -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 %}

34
tools/migrate Executable file
View file

@ -0,0 +1,34 @@
#!/usr/bin/env php
<?php
use Index\Data\Migration\FsDbMigrationRepo;
require_once __DIR__ . '/../seria.php';
try {
touch(SERIA_ROOT . '/.migrating');
chmod(SERIA_ROOT . '/.migrating', 0777);
echo 'Creating migration manager...' . PHP_EOL;
$manager = $seria->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');
}

25
tools/new-migration Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env php
<?php
use Index\Data\Migration\FsDbMigrationRepo;
require_once __DIR__ . '/../seria.php';
$repo = $seria->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;