Big overhauls for mcexts.

This commit is contained in:
flash 2023-08-22 23:47:37 +00:00
parent 1a4d2c0e39
commit 9687753926
32 changed files with 2584 additions and 23 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@
/config/config.ini
/public/robots.txt
/vendor
/public/textures

View file

@ -3,7 +3,8 @@
"prefer-stable": true,
"require": {
"flashwave/index": "*",
"twig/twig": "^3.7"
"twig/twig": "^3.7",
"ramsey/uuid": "^4.7"
},
"autoload": {
"classmap": [

242
composer.lock generated
View file

@ -4,15 +4,70 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "90e206da96cc83682b957dc89eb26f1d",
"content-hash": "e106ca43064ca7a0d1859825175fce72",
"packages": [
{
"name": "brick/math",
"version": "0.11.0",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "0ad82ce168c82ba30d1c01ec86116ab52f589478"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/0ad82ce168c82ba30d1c01ec86116ab52f589478",
"reference": "0ad82ce168c82ba30d1c01ec86116ab52f589478",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"php-coveralls/php-coveralls": "^2.2",
"phpunit/phpunit": "^9.0",
"vimeo/psalm": "5.0.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Brick\\Math\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Arbitrary-precision arithmetic library",
"keywords": [
"Arbitrary-precision",
"BigInteger",
"BigRational",
"arithmetic",
"bigdecimal",
"bignum",
"brick",
"math"
],
"support": {
"issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.11.0"
},
"funding": [
{
"url": "https://github.com/BenMorel",
"type": "github"
}
],
"time": "2023-01-15T23:15:59+00:00"
},
{
"name": "flashwave/index",
"version": "dev-master",
"source": {
"type": "git",
"url": "https://git.flash.moe/flash/index.git",
"reference": "a4c1d5627e590e669998f68cae64691b1ce0e0ac"
"reference": "6a38f803f4b3e49296f7472743e7c683c496ec19"
},
"require": {
"ext-mbstring": "*",
@ -50,7 +105,188 @@
],
"description": "Composer package for the common library for my projects.",
"homepage": "https://railgun.sh/index",
"time": "2023-08-16T23:03:01+00:00"
"time": "2023-08-22T00:04:20+00:00"
},
{
"name": "ramsey/collection",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/ramsey/collection.git",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/collection/zipball/a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"reference": "a4b48764bfbb8f3a6a4d1aeb1a35bb5e9ecac4a5",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"captainhook/plugin-composer": "^5.3",
"ergebnis/composer-normalize": "^2.28.3",
"fakerphp/faker": "^1.21",
"hamcrest/hamcrest-php": "^2.0",
"jangregor/phpstan-prophecy": "^1.0",
"mockery/mockery": "^1.5",
"php-parallel-lint/php-console-highlighter": "^1.0",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpcsstandards/phpcsutils": "^1.0.0-rc1",
"phpspec/prophecy-phpunit": "^2.0",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.9",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.3",
"phpunit/phpunit": "^9.5",
"psalm/plugin-mockery": "^1.1",
"psalm/plugin-phpunit": "^0.18.4",
"ramsey/coding-standard": "^2.0.3",
"ramsey/conventional-commits": "^1.3",
"vimeo/psalm": "^5.4"
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
},
"ramsey/conventional-commits": {
"configFile": "conventional-commits.json"
}
},
"autoload": {
"psr-4": {
"Ramsey\\Collection\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Ben Ramsey",
"email": "ben@benramsey.com",
"homepage": "https://benramsey.com"
}
],
"description": "A PHP library for representing and manipulating collections.",
"keywords": [
"array",
"collection",
"hash",
"map",
"queue",
"set"
],
"support": {
"issues": "https://github.com/ramsey/collection/issues",
"source": "https://github.com/ramsey/collection/tree/2.0.0"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/collection",
"type": "tidelift"
}
],
"time": "2022-12-31T21:50:55+00:00"
},
{
"name": "ramsey/uuid",
"version": "4.7.4",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
"reference": "60a4c63ab724854332900504274f6150ff26d286"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ramsey/uuid/zipball/60a4c63ab724854332900504274f6150ff26d286",
"reference": "60a4c63ab724854332900504274f6150ff26d286",
"shasum": ""
},
"require": {
"brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11",
"ext-json": "*",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
"replace": {
"rhumsaa/uuid": "self.version"
},
"require-dev": {
"captainhook/captainhook": "^5.10",
"captainhook/plugin-composer": "^5.3",
"dealerdirect/phpcodesniffer-composer-installer": "^0.7.0",
"doctrine/annotations": "^1.8",
"ergebnis/composer-normalize": "^2.15",
"mockery/mockery": "^1.3",
"paragonie/random-lib": "^2",
"php-mock/php-mock": "^2.2",
"php-mock/php-mock-mockery": "^1.3",
"php-parallel-lint/php-parallel-lint": "^1.1",
"phpbench/phpbench": "^1.0",
"phpstan/extension-installer": "^1.1",
"phpstan/phpstan": "^1.8",
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-phpunit": "^1.1",
"phpunit/phpunit": "^8.5 || ^9",
"ramsey/composer-repl": "^1.4",
"slevomat/coding-standard": "^8.4",
"squizlabs/php_codesniffer": "^3.5",
"vimeo/psalm": "^4.9"
},
"suggest": {
"ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.",
"ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.",
"ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.",
"paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
"ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
},
"type": "library",
"extra": {
"captainhook": {
"force-install": true
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Ramsey\\Uuid\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A PHP library for generating and working with universally unique identifiers (UUIDs).",
"keywords": [
"guid",
"identifier",
"uuid"
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
"source": "https://github.com/ramsey/uuid/tree/4.7.4"
},
"funding": [
{
"url": "https://github.com/ramsey",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/ramsey/uuid",
"type": "tidelift"
}
],
"time": "2023-04-15T23:01:58+00:00"
},
{
"name": "symfony/polyfill-ctype",

View file

@ -0,0 +1,100 @@
<?php
use Index\Data\IDbConnection;
use Index\Data\Migration\IDbMigration;
final class CreateNewTables_20230822_231052 implements IDbMigration {
public function migrate(IDbConnection $conn): void {
$conn->execute('
CREATE TABLE verifications (
verify_code BINARY(10) NOT NULL,
verify_uuid BINARY(16) NOT NULL,
verify_name VARCHAR(255) NOT NULL COLLATE "ascii_bin",
verify_addr VARBINARY(16) NOT NULL,
verify_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
PRIMARY KEY (verify_code),
UNIQUE KEY verifications_uuid_unique (verify_uuid),
UNIQUE KEY verifications_name_unique (verify_name),
KEY verifications_created_index (verify_created)
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
CREATE TABLE users (
user_id INT(10) UNSIGNED NOT NULL,
user_name VARCHAR(255) NOT NULL COLLATE "utf8mb4_unicode_520_ci",
user_colour INT(10) UNSIGNED NULL DEFAULT NULL,
PRIMARY KEY (user_id)
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
CREATE TABLE links (
user_id INT(10) UNSIGNED NOT NULL,
link_uuid BINARY(16) NOT NULL,
link_name VARCHAR(255) NOT NULL COLLATE "ascii_bin",
link_created TIMESTAMP NOT NULL DEFAULT current_timestamp(),
UNIQUE KEY links_user_foreign (user_id),
UNIQUE KEY links_uuid_unique (link_uuid),
UNIQUE KEY links_name_unique (link_name),
CONSTRAINT links_user_foreign
FOREIGN KEY (user_id)
REFERENCES users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
CREATE TABLE authorisations (
auth_id INT(10) UNSIGNED NOT NULL AUTO_INCREMENT,
auth_uuid BINARY(16) NOT NULL,
auth_addr VARBINARY(16) NOT NULL,
auth_requested TIMESTAMP NOT NULL DEFAULT current_timestamp(),
auth_granted TIMESTAMP NULL DEFAULT NULL,
auth_used TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (auth_id),
UNIQUE KEY authorisations_unique (auth_uuid, auth_addr),
KEY authorisations_uuid_foreign (auth_uuid),
KEY authorisations_granted_index (auth_granted),
KEY authorisations_requested_index (auth_requested),
KEY authorisations_used_index (auth_used),
CONSTRAINT authorisations_uuid_foreign
FOREIGN KEY (auth_uuid)
REFERENCES links (link_uuid)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
CREATE TABLE skins (
user_id INT(10) UNSIGNED NOT NULL,
skin_hash BINARY(32) NOT NULL,
skin_model ENUM("classic", "slim") NOT NULL COLLATE "ascii_general_ci",
skin_updated TIMESTAMP NOT NULL DEFAULT current_timestamp(),
UNIQUE KEY skins_user_foreign (user_id),
KEY skins_hash_index (skin_hash),
CONSTRAINT skins_user_foreign
FOREIGN KEY (user_id)
REFERENCES users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
$conn->execute('
CREATE TABLE capes (
user_id INT(10) UNSIGNED NOT NULL,
cape_hash BINARY(32) NOT NULL,
cape_updated TIMESTAMP NOT NULL DEFAULT current_timestamp(),
UNIQUE KEY capes_user_foreign (user_id),
KEY capes_hash_index (cape_hash),
CONSTRAINT capes_user_foreign
FOREIGN KEY (user_id)
REFERENCES users (user_id)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB COLLATE=utf8mb4_bin
');
}
}

Binary file not shown.

View file

@ -1,7 +1,6 @@
<?php
namespace Mince;
use Index\XString;
use Index\Http\HttpFx;
use Index\Security\CSRFP;
@ -9,11 +8,17 @@ require_once __DIR__ . '/../mince.php';
// replace this with id.flashii.net shit
$authToken = (string)filter_input(INPUT_COOKIE, 'msz_auth');
$userInfo = ChatAuth::attempt($db, $config['chat_endpoint'], $config['chat_secret'], $authToken);
$authInfo = ChatAuth::attempt($db, $config['chat_endpoint'], $config['chat_secret'], $authToken);
$users = new Users($db);
if($authInfo->success) {
$users->syncChatUser($authInfo);
$userInfo = $users->getUser($authInfo->user_id);
} else $userInfo = null;
$csrfp = new CSRFP(
$config['csrf_secret'],
$userInfo->success ? $authToken : $_SERVER['REMOTE_ADDR']
$authInfo->success ? $authToken : $_SERVER['REMOTE_ADDR']
);
$templating = new Templating;
@ -22,12 +27,19 @@ $templating->addPath(MCR_DIR_TPL);
$templating->addVars([
'global' => [
'title' => 'Flashii Minecraft Servers',
'loginUrl' => $config['login_url'],
],
'auth' => $userInfo,
'is_authed' => $userInfo !== null,
'auth' => $authInfo,
'user' => $userInfo,
'csrfp' => $csrfp->createToken(),
]);
$accountLinks = new AccountLinks($db);
$authorisations = new Authorisations($db);
$authorisations->prune();
$verifications = new Verifications($db);
$verifications->prune();
$router = new HttpFx;
$router->use('/', function($response, $request) {
$response->setPoweredBy('Mince');
@ -42,7 +54,12 @@ $router->setDefaultErrorHandler(function($response, $request, $code, $text) use
]));
});
(new HomeRoutes(new Servers($db), $templating, $userInfo))->register($router);
(new WhitelistRoutes(new Whitelist($db), $csrfp, $userInfo))->register($router);
(new RpcRoutes($users, $accountLinks, $authorisations, $verifications, $config['rpc_secret'], $config['clients_url']))->register($router);
(new HomeRoutes(new Servers($db), $templating, $authInfo, $config['login_url']))->register($router);
(new ClientsRoutes($templating, $accountLinks, $authorisations, $verifications, $csrfp, $authInfo))->register($router);
(new SkinsRoutes($templating, $accountLinks, new Skins($db), new Capes($db), $csrfp, $authInfo))->register($router);
(new WhitelistRoutes(new Whitelist($db), $csrfp, $authInfo))->register($router);
MojangInterop::registerRoutes($router);
$router->dispatch();

View file

@ -21,7 +21,13 @@ body,
body {
background: #111 url('/assets/bg_main.png');
color: #e0d0d0;
font: 13px/1.4 'Helvetica Neue', sans-serif;
font-family: 'Helvetica Neue', sans-serif;
font-size: 13px;
line-height: 1.4em;
}
h1, h2 {
line-height: 1.1em;
}
.wrapper {}
@ -38,8 +44,20 @@ body {
display: flex;
align-items: center;
}
.header-fat {
.header-menu {
flex: 1 1 auto;
margin: 0 10px;
font-size: 16px;
font-weight: 700;
}
.header-menu a {
margin-left: 10px;
color: #fff;
text-decoration: none;
}
.header-menu a:hover,
.header-menu a:focus {
text-decoration: underline;
}
.header-logo {
flex: 0 0 auto;
@ -85,6 +103,8 @@ body {
color: #f33;
}
.form-btn-green,
.acclink input[type="submit"],
.whitelist input[type="submit"] {
font-size: 1.5em;
margin: 5px;
@ -95,14 +115,22 @@ body {
color: #cfc;
transition: background-color .2s;
}
.form-btn-green:hover,
.form-btn-green:focus,
.acclink input[type="submit"]:hover,
.acclink input[type="submit"]:focus,
.whitelist input[type="submit"]:hover,
.whitelist input[type="submit"]:focus {
background-color: #272;
}
.form-btn-green:active,
.acclink input[type="submit"]:active,
.whitelist input[type="submit"]:active {
background-color: #232;
}
.form-btn-red,
.accunlink input[type="submit"],
.unwhitelist input[type="submit"] {
font-size: 1.5em;
margin: 5px;
@ -113,10 +141,16 @@ body {
color: #fcc;
transition: background-color .2s;
}
.form-btn-red:hover,
.form-btn-red:focus,
.accunlink input[type="submit"]:hover,
.accunlink input[type="submit"]:focus,
.unwhitelist input[type="submit"]:hover,
.unwhitelist input[type="submit"]:focus {
background-color: #722;
}
.form-btn-red:active,
.accunlink input[type="submit"]:active,
.unwhitelist input[type="submit"]:active {
background-color: #322;
}
@ -166,3 +200,169 @@ label .label-input input {
.servers tbody tr:nth-child(even) td {
background-color: #333;
}
ul > li {
margin-left: 1.5em;
list-style: square;
}
.accheader,
.acclink,
.accipaddr,
.accclients,
.accmegadeauth,
.accunlink {
margin-bottom: 40px;
}
.accclients table {
margin: 0 auto;
font-size: 1.2em;
line-height: 1.4em;
border-spacing: 1px;
min-width: 600px;
}
.accclients table th {
border-bottom: 1px solid #888;
background-image: linear-gradient(0deg, #444 0, transparent 50%);
}
.accclients table th {
padding: 0 15px;
}
.accclients table td:not(.actions) {
padding: 5px 15px;
}
.accclients .ipaddr {
font-weight: 700;
}
.accclients .ipaddr,
.accclients .request,
.accclients .granted,
.accclients .used {
text-align: center;
}
.accclients tbody tr:nth-child(odd) td {
background-color: #222;
}
.accclients tbody tr:nth-child(even) td {
background-color: #333;
}
.accclients .actions .action {
width: 100%;
padding: 5px;
}
.accclients .actions form:nth-child(1) {
padding: 5px;
}
.accclients .actions form:nth-child(2) {
padding: 5px;
padding-top: 0;
}
.accclients .actions .action-authorise {
border-radius: 4px;
border: 1px solid #2a2;
background-color: #252;
color: #cfc;
transition: background-color .2s;
}
.accclients .actions .action-authorise:hover,
.accclients .actions .action-authorise:focus {
background-color: #272;
}
.accclients .actions .action-authorise:active {
background-color: #232;
}
.accclients .actions .action-deauthorise {
border-radius: 4px;
border: 1px solid #a22;
background-color: #522;
color: #fcc;
transition: background-color .2s;
}
.accclients .actions .action-deauthorise:hover,
.accclients .actions .action-deauthorise:focus {
background-color: #722;
}
.accclients .actions .action-deauthorise:active {
background-color: #322;
}
.skins-header {
margin-bottom: 40px;
}
.skin,
.cape,
.skinimport {
margin-bottom: 40px;
}
.skins-form {
display: flex;
gap: 4px;
}
.skins-form form {
display: flex;
gap: 4px;
}
.skins-form select {
color: #fff;
border: 1px solid #555;
padding: 1px 5px;
border-radius: 4px;
background-color: #333;
}
.skins-form input[type="file"] {
color: #fff;
border: 1px solid #555;
padding: 1px;
border-radius: 4px;
background-color: #333;
}
.skins-form input[type="submit"] {
border-radius: 4px;
padding: 4px;
border: 1px solid #000;
}
.skins-form-upload {
border-color: #2a2 !important;
background-color: #252;
color: #cfc;
transition: background-color .2s;
}
.skins-form-upload:hover,
.skins-form-upload:focus {
background-color: #272;
}
.skins-form-upload:active {
background-color: #232;
}
.skins-form-delete {
border-color: #a22 !important;
background-color: #522;
color: #fcc;
transition: background-color .2s;
}
.skins-form-delete:hover,
.skins-form-delete:focus {
background-color: #722;
}
.skins-form-delete:active {
background-color: #322;
}
.skins-preview {
margin-top: 4px;
}
.skins-preview img {
border: 1px solid #555;
background-color: #333;
padding: 2px;
border-radius: 4px;
width: 256px;
image-rendering: pixelated;
vertical-align: middle;
}
.skins-preview img:hover {
background-color: #ccc;
}

45
src/AccountLinkInfo.php Normal file
View file

@ -0,0 +1,45 @@
<?php
namespace Mince;
use Index\DateTime;
use Index\Data\IDbResult;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class AccountLinkInfo {
private string $userId;
private string $uuid;
private string $name;
private int $created;
public function __construct(IDbResult $result) {
$this->userId = $result->getString(0);
$this->uuid = $result->getString(1);
$this->name = $result->getString(2);
$this->created = $result->getInteger(3);
}
public function getUserId(): string {
return $this->userId;
}
public function getUUIDRaw(): string {
return $this->uuid;
}
public function getUUID(): UuidInterface {
return Uuid::fromBytes($this->uuid);
}
public function getName(): string {
return $this->name;
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
}

122
src/AccountLinks.php Normal file
View file

@ -0,0 +1,122 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Ramsey\Uuid\UuidInterface;
class AccountLinks {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function checkHasLink(UserInfo|string $userInfo): bool {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('SELECT COUNT(*) FROM links WHERE user_id = ?');
$stmt->addParameter(1, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() && $result->getInteger(0) > 0;
}
public function getLink(
UserInfo|string|null $userInfo = null,
UuidInterface|string|null $uuid = null,
?string $name = null
): AccountLinkInfo {
$hasUserInfo = $userInfo !== null;
$hasUuid = $uuid !== null;
$hasName = $name !== null;
if(!$hasUserInfo && !$hasUuid && !$hasName)
throw new InvalidArgumentException('At least one argument must be specified.');
if(($hasUserInfo && ($hasUuid || $hasName))
|| ($hasUuid && ($hasUserInfo || $hasName))
|| ($hasName && ($hasUuid || $hasUserInfo)))
throw new InvalidArgumentException('Only one argument may be used.');
$field = null;
$value = null;
if($hasUserInfo) {
$field = 'user_id';
$value = $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo;
} elseif($hasUuid) {
$field = 'link_uuid';
$value = $uuid instanceof UuidInterface ? $uuid->getBytes() : $uuid;
} elseif($hasName) {
$field = 'link_name';
$value = $name;
}
$stmt = $this->cache->get(sprintf('SELECT user_id, link_uuid, link_name, UNIX_TIMESTAMP(link_created) FROM links WHERE %s = ?', $field));
$stmt->addParameter(1, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Link info not found.');
return new AccountLinkInfo($result);
}
public function createLink(
UserInfo|string $userInfo,
VerificationInfo|UuidInterface|string $uuid,
?string $name = null
): void {
if($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
if($uuid instanceof VerificationInfo) {
$name = $uuid->getName();
$uuid = $uuid->getUUIDRaw();
} else {
if($name === null)
throw new InvalidArgumentException('$name may not be null (unless $uuid is a valid VerificationInfo instance)');
if($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
}
$stmt = $this->cache->get('INSERT INTO links (user_id, link_uuid, link_name) VALUES (?, ?, ?)');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $uuid);
$stmt->addParameter(3, $name);
$stmt->execute();
}
public function deleteLink(
UserInfo|string|null $userInfo = null,
UuidInterface|string|null $uuid = null
): void {
$hasUserInfo = $userInfo !== null;
$hasUuid = $uuid !== null;
if(!$hasUserInfo && !$hasUuid)
throw new InvalidArgumentException('At least one argument must be specified.');
if($hasUserInfo && $hasUuid)
throw new InvalidArgumentException('Only one argument may be used.');
$name = null;
$value = null;
if($hasUserInfo) {
$name = 'user_id';
$value = $userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo;
} elseif($hasUuid) {
$name = 'link_uuid';
$value = $uuid instanceof UuidInterface ? $uuid->getBytes() : $uuid;
}
$stmt = $this->cache->get(sprintf('DELETE FROM links WHERE %s = ?', $name));
$stmt->addParameter(1, $value);
$stmt->execute();
}
}

82
src/AuthorisationInfo.php Normal file
View file

@ -0,0 +1,82 @@
<?php
namespace Mince;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class AuthorisationInfo {
private string $id;
private string $uuid;
private string $addr;
private int $requested;
private ?int $granted;
private ?int $used;
public function __construct(IDbResult $result) {
$this->id = $result->getString(0);
$this->uuid = $result->getString(1);
$this->addr = $result->getString(2);
$this->requested = $result->getInteger(3);
$this->granted = $result->isNull(4) ? null : $result->getInteger(4);
$this->used = $result->isNull(5) ? null : $result->getInteger(5);
}
public function getId(): string {
return $this->id;
}
public function getUUIDRaw(): string {
return $this->uuid;
}
public function getUUID(): UuidInterface {
return Uuid::fromBytes($this->uuid);
}
public function getAddressRaw(): string {
return $this->addr;
}
public function getAddress(): IPAddress {
return IPAddress::parse($this->addr);
}
public function getRequestedTime(): int {
return $this->requested;
}
public function getRequestedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->requested);
}
public function isPending(): bool {
return $this->granted === null;
}
public function isGranted(): bool {
return $this->granted !== null;
}
public function getGrantedTime(): ?int {
return $this->granted;
}
public function getGrantedAt(): ?DateTime {
return $this->granted === null ? null : DateTime::fromUnixTimeSeconds($this->granted);
}
public function isUsed(): bool {
return $this->used !== null;
}
public function getLastUsedTime(): ?int {
return $this->used;
}
public function getLastUsedAt(): ?DateTime {
return $this->used === null ? null : DateTime::fromUnixTimeSeconds($this->used);
}
}

182
src/Authorisations.php Normal file
View file

@ -0,0 +1,182 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Ramsey\Uuid\UuidInterface;
class Authorisations {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
public function prune(): void {
$this->dbConn->execute('DELETE FROM authorisations WHERE (auth_requested < NOW() - INTERVAL 1 HOUR AND auth_granted IS NULL) OR (auth_used < NOW() - INTERVAL 1 WEEK AND auth_granted IS NOT NULL)');
}
public function getAuthorisations(
AccountLinkInfo|UuidInterface|string $uuid
): array {
if($uuid instanceof AccountLinkInfo)
$uuid = $uuid->getUUIDRaw();
elseif($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
$stmt = $this->cache->get('SELECT auth_id, auth_uuid, INET6_NTOA(auth_addr), UNIX_TIMESTAMP(auth_requested), UNIX_TIMESTAMP(auth_granted), UNIX_TIMESTAMP(auth_used) FROM authorisations WHERE auth_uuid = ? ORDER BY auth_granted IS NULL DESC, auth_granted DESC, auth_requested DESC');
$stmt->addParameter(1, $uuid);
$stmt->execute();
$clients = [];
$result = $stmt->getResult();
while($result->next())
$clients[] = new AuthorisationInfo($result);
return $clients;
}
public function getAuthorisation(
?string $authId = null,
AccountLinkInfo|UuidInterface|string|null $uuid = null,
IPAddress|string|null $remoteAddr = null
): AuthorisationInfo {
$hasAuthId = $authId !== null;
$hasUuid = $uuid !== null;
$hasRemoteAddr = $remoteAddr !== null;
$values = [];
$query = 'SELECT auth_id, auth_uuid, INET6_NTOA(auth_addr), UNIX_TIMESTAMP(auth_requested), UNIX_TIMESTAMP(auth_granted), UNIX_TIMESTAMP(auth_used) FROM authorisations';
if($hasAuthId) {
$query .= ' WHERE auth_id = ?';
$values[] = $authId;
} else {
$args = 0;
if($hasUuid) {
if($uuid instanceof AccountLinkInfo)
$uuid = $uuid->getUUIDRaw();
elseif($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
++$args;
$query .= ' WHERE auth_uuid = ?';
$values[] = $uuid;
}
if($hasRemoteAddr) {
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$query .= sprintf(' %s auth_addr = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
$values[] = $remoteAddr;
}
}
if(empty($values))
throw new InvalidArgumentException('At least one argument must be specified.');
$args = 0;
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->addParameter(++$args, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Authorisation info not found.');
return new AuthorisationInfo($result);
}
public function createAuthorisation(
AccountLinkInfo|VerificationInfo|UuidInterface|string $uuid,
IPAddress|string|null $remoteAddr = null,
bool $grant = false
): void {
if($uuid instanceof VerificationInfo) {
$remoteAddr = $uuid->getAddressRaw();
$uuid = $uuid->getUUIDRaw();
} else {
if($remoteAddr === null)
throw new InvalidArgumentException('$remoteAddr may not be null (unless $uuid is a valid VerificationInfo instance)');
if($uuid instanceof AccountLinkInfo)
$uuid = $uuid->getUUIDRaw();
elseif($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
}
$stmt = $this->cache->get(sprintf(
'INSERT INTO authorisations (auth_uuid, auth_addr, auth_granted) VALUES (?, INET6_ATON(?), %s)',
$grant ? 'NOW()' : 'NULL'
));
$stmt->addParameter(1, $uuid);
$stmt->addParameter(2, $remoteAddr);
$stmt->execute();
}
public function setAuthorisationGranted(AuthorisationInfo|string $authInfo): void {
if($authInfo instanceof AuthorisationInfo)
$authInfo = $authInfo->getId();
$stmt = $this->cache->get('UPDATE authorisations SET auth_granted = COALESCE(auth_granted, NOW()) WHERE auth_id = ?');
$stmt->addParameter(1, $authInfo);
$stmt->execute();
}
public function markAuthorisationUsed(AuthorisationInfo|string $authInfo): void {
if($authInfo instanceof AuthorisationInfo)
$authInfo = $authInfo->getId();
$stmt = $this->cache->get('UPDATE authorisations SET auth_used = NOW() WHERE auth_id = ?');
$stmt->addParameter(1, $authInfo);
$stmt->execute();
}
public function deleteAuthorisations(
AuthorisationInfo|string|null $authInfo = null,
AccountLinkInfo|UuidInterface|string|null $uuid = null,
?bool $pending = null
): void {
$hasAuthInfo = $authInfo !== null;
$hasUuid = $uuid !== null;
$hasPending = $pending !== null;
if(!$hasAuthInfo && !$hasUuid)
throw new InvalidArgumentException('$authInfo or $uuid must be specified.');
$value = null;
$query = 'DELETE FROM authorisations';
if($hasAuthInfo) {
if($authInfo instanceof AuthorisationInfo)
$authInfo = $authInfo->getId();
$query .= ' WHERE auth_id = ?';
$value = $authInfo;
} elseif($hasUuid) {
if($uuid instanceof AccountLinkInfo)
$uuid = $uuid->getUUIDRaw();
elseif($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
$query .= ' WHERE auth_uuid = ?';
$value = $uuid;
if($hasPending)
$query .= sprintf(' AND auth_granted %s NULL', $pending ? 'IS' : 'IS NOT');
}
$stmt = $this->cache->get($query);
$stmt->addParameter(1, $value);
$stmt->execute();
}
}

33
src/CapeInfo.php Normal file
View file

@ -0,0 +1,33 @@
<?php
namespace Mince;
use Index\DateTime;
use Index\Data\IDbResult;
class CapeInfo {
private string $userId;
private string $hash;
private int $updated;
public function __construct(IDbResult $result) {
$this->userId = $result->getString(0);
$this->hash = $result->getString(1);
$this->updated = $result->getInteger(2);
}
public function getUserId(): string {
return $this->userId;
}
public function getHash(): string {
return $this->hash;
}
public function getUpdatedTime(): int {
return $this->updated;
}
public function getUpdatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->updated);
}
}

84
src/Capes.php Normal file
View file

@ -0,0 +1,84 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class Capes {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function checkHash(string $hash): bool {
if(!ctype_xdigit($hash) || strlen($hash) !== 64)
throw new InvalidArgumentException('$hash is not a valid hash string.');
$stmt = $this->cache->get('SELECT COUNT(*) FROM capes WHERE cape_hash = UNHEX(?)');
$stmt->addParameter(1, $hash);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() && $result->getInteger(0) > 0;
}
public function getCape(AccountLinkInfo|UserInfo|string $userInfo): ?CapeInfo {
if($userInfo instanceof AccountLinkInfo)
$userInfo = $userInfo->getUserId();
elseif($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('SELECT user_id, LOWER(HEX(cape_hash)), UNIX_TIMESTAMP(cape_updated) FROM capes WHERE user_id = ?');
$stmt->addParameter(1, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? new CapeInfo($result) : null;
}
public function updateCape(AccountLinkInfo|UserInfo|string $userInfo, string $hash): void {
if(!ctype_xdigit($hash) || strlen($hash) !== 64)
throw new InvalidArgumentException('$hash is not a valid hash string.');
if($userInfo instanceof AccountLinkInfo)
$userInfo = $userInfo->getUserId();
elseif($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('REPLACE INTO capes (user_id, cape_hash) VALUES (?, UNHEX(?))');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $hash);
$stmt->execute();
}
public function deleteCape(
AccountLinkInfo|UserInfo|string|null $userInfo = null,
?string $hash = null
): void {
$hasUserInfo = $userInfo !== null;
$hasHash = $hash !== null;
if(!$hasUserInfo && !$hasHash)
throw new InvalidArgumentException('At least one argument must be specified.');
if($hasUserInfo && $hasHash)
throw new InvalidArgumentException('Only one argument may be specified.');
$value = null;
$query = 'DELETE FROM capes';
if($hasUserInfo) {
$query .= ' WHERE user_id = ?';
$value = $userInfo instanceof AccountLinkInfo
? $userInfo->getUserId()
: ($userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
} elseif($hasHash) {
$query .= ' WHERE cape_hash = UNHEX(?)';
$value = $hash;
}
$stmt = $this->cache->get($query);
$stmt->addParameter(1, $value);
$stmt->execute();
}
}

145
src/ClientsRoutes.php Normal file
View file

@ -0,0 +1,145 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Routing\IRouter;
use Index\Security\CSRFP;
use Ramsey\Uuid\Uuid;
class ClientsRoutes {
public function __construct(
private Templating $templating,
private AccountLinks $accountLinks,
private Authorisations $authorisations,
private Verifications $verifications,
private CSRFP $csrfp,
private object $authInfo
) {}
public function register(IRouter $router): void {
$router->use('/clients', [$this, 'verifyRequest']);
$router->get('/clients', [$this, 'getClients']);
$router->post('/clients/link', [$this, 'postLink']);
$router->post('/clients/unlink', [$this, 'postUnlink']);
$router->post('/clients/authorise', [$this, 'postAuthorise']);
$router->post('/clients/deauthorise', [$this, 'postDeauthorise']);
}
public function verifyRequest($response, $request) {
if(!$this->authInfo->success)
return 403;
if($request->getMethod() === 'POST') {
if(!$request->isFormContent())
return 400;
$body = $request->getContent();
if(!$body->hasParam('csrfp') || !$this->csrfp->verifyToken((string)$body->getParam('csrfp')))
return 403;
}
}
public function getClients() {
try {
$linkInfo = $this->accountLinks->getLink(userInfo: $this->authInfo->user_id);
$clients = $this->authorisations->getAuthorisations($linkInfo);
$this->templating->addVars([
'link' => $linkInfo,
'clients' => $clients,
]);
} catch(RuntimeException $ex) {}
return $this->templating->render('clients/index');
}
public function postLink($response, $request) {
if($this->accountLinks->checkHasLink($this->authInfo->user_id))
return 403;
$body = $request->getContent();
$code = (string)$body->getParam('code');
if(strlen($code) !== 10)
return 400;
$code = strtr(strtoupper($code), '0189', 'OIBG');
try {
$verifyInfo = $this->verifications->getVerification(code: $code);
} catch(RuntimeException $ex) {
return 404;
}
$this->verifications->deleteVerification($verifyInfo);
$this->accountLinks->createLink($this->authInfo->user_id, $verifyInfo);
$this->authorisations->createAuthorisation($verifyInfo, grant: true);
$response->redirect('/clients');
}
public function postUnlink($response) {
$this->accountLinks->deleteLink(userInfo: $this->authInfo->user_id);
$response->redirect('/clients');
}
public function postAuthorise($response, $request) {
$body = $request->getContent();
$authId = (string)$body->getParam('auth');
if(empty($authId))
return 404;
try {
$linkInfo = $this->accountLinks->getLink(userInfo: $this->authInfo->user_id);
} catch(RuntimeException $ex) {
return 403;
}
try {
$authInfo = $this->authorisations->getAuthorisation(authId: $authId);
} catch(RuntimeException $ex) {
return 403;
}
if($authInfo->getUUIDRaw() !== $linkInfo->getUUIDRaw())
return 403;
if($authInfo->isGranted())
return 404;
$this->authorisations->setAuthorisationGranted($authInfo);
$response->redirect('/clients');
}
public function postDeauthorise($response, $request) {
$body = $request->getContent();
$authId = (string)$body->getParam('auth');
if(empty($authId))
return 404;
try {
$linkInfo = $this->accountLinks->getLink(userInfo: $this->authInfo->user_id);
} catch(RuntimeException $ex) {
return 403;
}
if($authId === 'all') {
$this->authorisations->deleteAuthorisations(uuid: $linkInfo);
} elseif($authId === 'pending') {
$this->authorisations->deleteAuthorisations(uuid: $linkInfo, pending: true);
} else {
try {
$authInfo = $this->authorisations->getAuthorisation(authId: $authId);
} catch(RuntimeException $ex) {
return 403;
}
if($authInfo->getUUIDRaw() !== $linkInfo->getUUIDRaw())
return 403;
$this->authorisations->deleteAuthorisations(authInfo: $authInfo);
}
$response->redirect('/clients');
}
}

View file

@ -7,12 +7,14 @@ class HomeRoutes {
public function __construct(
private Servers $servers,
private Templating $templating,
private object $userInfo
private object $userInfo,
private string $loginUrl
) {}
public function register(IRouter $router): void {
$router->get('/', [$this, 'getIndex']);
$router->get('/downloads', [$this, 'getDownloads']);
$router->get('/login', fn($response) => $response->redirect($this->userInfo->success ? '/' : $this->loginUrl));
$router->get('/index.php', function($response) {
$response->redirect('/', true);
});
@ -59,4 +61,8 @@ class HomeRoutes {
'wladdform_username' => $name,
]);
}
public function getDownloads() {
return $this->templating->render('downloads');
}
}

142
src/MojangInterop.php Normal file
View file

@ -0,0 +1,142 @@
<?php
namespace Mince;
use stdClass;
use Index\Http\HttpResponseBuilder;
use Index\Http\HttpRequest;
use Index\Routing\IRouter;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
final class MojangInterop {
private const API_SERVER = 'https://api.mojang.com';
private const SESSION_SERVER = 'https://sessionserver.mojang.com';
public static function currentTime(): int {
return (int)(microtime(true) * 1000);
}
public static function nameUUIDFromBytes(string $data): UuidInterface {
$bytes = hash('md5', $data, true);
$bytes[6] = chr((ord($bytes[6]) & 0x0F) | 0x30); // set version 3
$bytes[8] = chr((ord($bytes[8]) & 0x3F) | 0x80); // set IETF variant
return Uuid::fromBytes($bytes);
}
public static function createOfflinePlayerUUID(string $name): UuidInterface {
return self::nameUUIDFromBytes(sprintf('OfflinePlayer:%s', $name));
}
public static function isOfflineId(UuidInterface $uuid): bool {
return $uuid->getVersion() === 3;
}
public static function isMojangId(UuidInterface $uuid): bool {
return $uuid->getVersion() === 4;
}
public static function registerRoutes(IRouter $router): void {
$router->get('/uuid', fn($response, $request) => self::uuidResolver($request));
$router->get('/blockedservers', fn($response, $request) => self::proxyBlockServers($response, $request));
// figure out how to proxy these someday to keep online mode working transparently
$router->get('/session/minecraft/hasJoined', fn() => 501);
$router->post('/session/minecraft/join', fn() => 501);
}
public static function uuidResolver(HttpRequest $request): string {
return (string)self::createOfflinePlayerUUID((string)$request->getParam('name'))->getHex();
}
public static function getRequest(string $url, string $userAgent): object {
$out = new stdClass;
$curl = curl_init($url);
curl_setopt_array($curl, [
CURLOPT_AUTOREFERER => true,
CURLOPT_FAILONERROR => false,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TCP_NODELAY => true,
CURLOPT_HEADER => true,
CURLOPT_NOBODY => false,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TCP_FASTOPEN => true,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_TIMEOUT => 5,
CURLOPT_USERAGENT => $userAgent,
]);
[$out->headers, $out->body] = explode("\r\n\r\n", curl_exec($curl));
curl_close($curl);
$out->headers = explode("\r\n", $out->headers);
$out->status = explode(' ', array_shift($out->headers), 3);
return $out;
}
public static function sessionServerGetRequest(string $path, string $userAgent): object {
return self::getRequest(self::SESSION_SERVER . $path, $userAgent);
}
public static function apiServerGetRequest(string $path, string $userAgent): object {
return self::getRequest(self::API_SERVER . $path, $userAgent);
}
public static function getBlockedServersRaw(string $userAgent): object {
return self::sessionServerGetRequest('/blockedservers', $userAgent);
}
public static function proxyBlockServers(HttpResponseBuilder $response, HttpRequest $request): string {
$info = self::getBlockedServersRaw($request->getHeaderLine('User-Agent'));
$response->setStatusCode((int)$info->status[1]);
$response->setCacheControl('max-age=300');
foreach($info->headers as $header) {
[$name, $value] = explode(':', $header);
$name = strtolower(trim($name));
if(str_starts_with($name, 'x-') || $name === 'content-type')
$response->setHeader($name, $value);
}
return $info->body;
}
public static function getMinecraftUUIDRaw(string $userName, string $userAgent): object {
return self::apiServerGetRequest(sprintf('/users/profiles/minecraft/%s', $userName), $userAgent);
}
public static function getMinecraftUUID(string $userName, string $userAgent): ?object {
$info = self::getMinecraftUUIDRaw($userName, $userAgent);
if($info->status[1] !== '200')
return null;
return json_decode($info->body);
}
public static function getSessionMinecraftProfileRaw(string $uuid, string $userAgent): object {
return self::sessionServerGetRequest(sprintf('/session/minecraft/profile/%s', $uuid), $userAgent);
}
public static function getSessionMinecraftProfile(string $uuid, string $userAgent): ?object {
$info = self::getSessionMinecraftProfileRaw($uuid, $userAgent);
if($info->status[1] !== '200')
return null;
return json_decode($info->body);
}
public static function proxySessionMinecraftProfile(HttpResponseBuilder $response, HttpRequest $request, string $uuid): string {
$info = self::getSessionMinecraftProfileRaw($uuid, $request->getHeaderLine('User-Agent'));
$response->setStatusCode((int)$info->status[1]);
$response->setCacheControl('max-age=30');
foreach($info->headers as $header) {
[$name, $value] = explode(':', $header);
$name = strtolower(trim($name));
if(str_starts_with($name, 'x-') || $name === 'content-type')
$response->setHeader($name, $value);
}
return $info->body;
}
}

149
src/RpcRoutes.php Normal file
View file

@ -0,0 +1,149 @@
<?php
namespace Mince;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Stringable;
use Index\Routing\IRouter;
use Ramsey\Uuid\Uuid;
class RpcRoutes {
public function __construct(
private Users $users,
private AccountLinks $accountLinks,
private Authorisations $authorisations,
private Verifications $verifications,
private string $secretKey,
private string $clientsUrl
) {}
public function register(IRouter $router): void {
$router->use('/rpc', [$this, 'verifyRequest']);
$router->post('/rpc/auth', [$this, 'postAuth']);
}
private static function createPayload(string $name, array $attrs = []): object {
$payload = new stdClass;
$payload->name = $name;
$payload->attrs = [];
foreach($attrs as $name => $value) {
if($value === null)
continue;
if(!is_scalar($value))
$value = (string)$value;
$payload->attrs[] = ['name' => (string)$name, 'value' => $value];
}
return $payload;
}
private static function createErrorPayload(string $code, ?string $text = null): object {
$attrs = ['code' => $code];
if($text !== null && $text !== '')
$attrs['text'] = $text;
return self::createPayload('error', $attrs);
}
public function verifyRequest($response, $request) {
$userTime = (int)$request->getHeaderLine('X-Mince-Time');
$userHash = base64_decode((string)$request->getHeaderLine('X-Mince-Hash'));
$currentTime = time();
if(empty($userHash) || $userTime < $currentTime - 60 || $userTime > $currentTime + 60)
return self::createErrorPayload('verification', 'Request verification failed.');
$paramString = $request->getParamString();
if($request->getMethod() === 'POST') {
if(!$request->isFormContent())
return self::createErrorPayload('request', 'Request body is not in expect format.');
$content = $request->getContent();
$bodyString = $content->getParamString();
if(!empty($paramString) && !empty($bodyString))
$paramString .= '&';
$paramString .= $bodyString;
}
$verifyText = (string)$userTime . '#' . $request->getPath() . '?' . $paramString;
$verifyHash = hash_hmac('sha256', $verifyText, $this->secretKey, true);
if(!hash_equals($verifyHash, $userHash))
return self::createErrorPayload('verification', 'Request verification failed.');
}
public function postAuth($response, $request) {
$body = $request->getContent();
$id = (string)$body->getParam('id');
$name = (string)$body->getParam('name');
$addr = (string)$body->getParam('ip');
if(empty($name))
return self::createErrorPayload('auth:username', 'Username is invalid.');
if(!filter_var($addr, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4))
return self::createErrorPayload('auth:address', 'IP Address is invalid (must be IPv4).');
try {
$uuid = Uuid::fromString($id);
} catch(InvalidArgumentException $ex) {
return self::createErrorPayload('auth:id', 'ID is not a valid UUID format.');
}
if(MojangInterop::isOfflineId($uuid)) {
if(!MojangInterop::createOfflinePlayerUUID($name)->equals($uuid))
return self::createErrorPayload('auth:offline:tamper', 'ID does not match the expected value, are you trying to tamper?');
} elseif(MojangInterop::isMojangId($uuid)) {
// i think there's very little reason to actually support this, offline mode completely forgoes it
return self::createErrorPayload('auth:mojang:impl', 'Mojang ID verification not implemented lol sorry.');
} else
return self::createErrorPayload('auth:uuid', 'Provided UUID isn\'t an offline ID nor a Mojang ID.');
try {
$linkInfo = $this->accountLinks->getLink(uuid: $uuid);
} catch(RuntimeException $ex) {
$linkInfo = null;
}
if($linkInfo !== null) {
try {
$authInfo = $this->authorisations->getAuthorisation(uuid: $linkInfo, remoteAddr: $addr);
if($authInfo->isGranted()) {
$this->authorisations->markAuthorisationUsed($authInfo);
return self::createPayload('auth:ok');
}
} catch(RuntimeException $ex) {
$authInfo = null;
}
try {
$userInfo = $this->users->getUser(userId: $linkInfo->getUserId());
} catch(RuntimeException $ex) {
return 500;
}
if($authInfo === null)
$this->authorisations->createAuthorisation($uuid, $addr);
return self::createPayload('auth:authorise', [
'user_id' => $userInfo->getId(),
'user_name' => $userInfo->getName(),
'user_colour' => $userInfo->getColourRaw(),
'url' => $this->clientsUrl,
]);
}
try {
$verifyInfo = $this->verifications->getVerification(uuid: $uuid, remoteAddr: $addr);
$verifyCode = $verifyInfo->getCode();
} catch(RuntimeException $ex) {
$verifyCode = $this->verifications->createVerification($uuid, $name, $addr);
}
return self::createPayload('auth:link', [
'code' => $verifyCode,
'url' => $this->clientsUrl,
]);
}
}

43
src/SkinInfo.php Normal file
View file

@ -0,0 +1,43 @@
<?php
namespace Mince;
use Index\DateTime;
use Index\Data\IDbResult;
class SkinInfo {
private string $userId;
private string $hash;
private string $model;
private int $updated;
public function __construct(IDbResult $result) {
$this->userId = $result->getString(0);
$this->hash = $result->getString(1);
$this->model = $result->getString(2);
$this->updated = $result->getInteger(3);
}
public function getUserId(): string {
return $this->userId;
}
public function getHash(): string {
return $this->hash;
}
public function getModel(): string {
return $this->model;
}
public function isClassic(): bool {
return $this->model === 'classic';
}
public function getUpdatedTime(): int {
return $this->updated;
}
public function getUpdatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->updated);
}
}

89
src/Skins.php Normal file
View file

@ -0,0 +1,89 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class Skins {
public const MODELS = ['classic', 'slim'];
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
public function checkHash(string $hash): bool {
if(!ctype_xdigit($hash) || strlen($hash) !== 64)
throw new InvalidArgumentException('$hash is not a valid hash string.');
$stmt = $this->cache->get('SELECT COUNT(*) FROM skins WHERE skin_hash = UNHEX(?)');
$stmt->addParameter(1, $hash);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() && $result->getInteger(0) > 0;
}
public function getSkin(AccountLinkInfo|UserInfo|string $userInfo): ?SkinInfo {
if($userInfo instanceof AccountLinkInfo)
$userInfo = $userInfo->getUserId();
elseif($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('SELECT user_id, LOWER(HEX(skin_hash)), skin_model, UNIX_TIMESTAMP(skin_updated) FROM skins WHERE user_id = ?');
$stmt->addParameter(1, $userInfo);
$stmt->execute();
$result = $stmt->getResult();
return $result->next() ? new SkinInfo($result) : null;
}
public function updateSkin(AccountLinkInfo|UserInfo|string $userInfo, string $hash, string $model): void {
if(!ctype_xdigit($hash) || strlen($hash) !== 64)
throw new InvalidArgumentException('$hash is not a valid hash string.');
if(!in_array($model, self::MODELS))
throw new InvalidArgumentException('$model is not a valid skin model.');
if($userInfo instanceof AccountLinkInfo)
$userInfo = $userInfo->getUserId();
elseif($userInfo instanceof UserInfo)
$userInfo = $userInfo->getId();
$stmt = $this->cache->get('REPLACE INTO skins (user_id, skin_hash, skin_model) VALUES (?, UNHEX(?), ?)');
$stmt->addParameter(1, $userInfo);
$stmt->addParameter(2, $hash);
$stmt->addParameter(3, $model);
$stmt->execute();
}
public function deleteSkin(
AccountLinkInfo|UserInfo|string|null $userInfo = null,
?string $hash = null
): void {
$hasUserInfo = $userInfo !== null;
$hasHash = $hash !== null;
if(!$hasUserInfo && !$hasHash)
throw new InvalidArgumentException('At least one argument must be specified.');
if($hasUserInfo && $hasHash)
throw new InvalidArgumentException('Only one argument may be specified.');
$value = null;
$query = 'DELETE FROM skins';
if($hasUserInfo) {
$query .= ' WHERE user_id = ?';
$value = $userInfo instanceof AccountLinkInfo
? $userInfo->getUserId()
: ($userInfo instanceof UserInfo ? $userInfo->getId() : $userInfo);
} elseif($hasHash) {
$query .= ' WHERE skin_hash = UNHEX(?)';
$value = $hash;
}
$stmt = $this->cache->get($query);
$stmt->addParameter(1, $value);
$stmt->execute();
}
}

400
src/SkinsRoutes.php Normal file
View file

@ -0,0 +1,400 @@
<?php
namespace Mince;
use Imagick;
use ImagickException;
use ImagickPixel;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Routing\IRouter;
use Index\Security\CSRFP;
use Ramsey\Uuid\Uuid;
class SkinsRoutes {
private const TEXTURES_DIR = '/textures';
private const TEXTURES_PATH = MCR_DIR_PUB . self::TEXTURES_DIR;
private AccountLinkInfo $linkInfo;
public function __construct(
private Templating $templating,
private AccountLinks $accountLinks,
private Skins $skins,
private Capes $capes,
private CSRFP $csrfp,
private object $authInfo
) {
if(!is_dir(self::TEXTURES_PATH))
throw new RuntimeException('Textures directory does not exist.');
if(!is_writable(self::TEXTURES_PATH))
throw new RuntimeException('Textures directory is not writable.');
}
public function register(IRouter $router): void {
$router->use('/skins', [$this, 'verifyRequest']);
$router->get('/skins', [$this, 'getSkins']);
$router->post('/skins/upload-skin', [$this, 'postUploadSkin']);
$router->post('/skins/delete-skin', [$this, 'postDeleteSkin']);
$router->post('/skins/upload-cape', [$this, 'postUploadCape']);
$router->post('/skins/delete-cape', [$this, 'postDeleteCape']);
$router->post('/skins/import', [$this, 'postImport']);
$router->get('/session/minecraft/profile/:id', [$this, 'getSessionMinecraftProfile']);
$router->get('/users/profiles/minecraft/:name', [$this, 'getUsersMinecraftProfile']);
}
public function checkHash(string $hash): bool {
return $this->skins->checkHash($hash)
|| $this->capes->checkHash($hash);
}
public function getLocalPath(string $hash): string {
return self::TEXTURES_PATH . '/' . $hash . '.png';
}
public function getRemotePath(string $hash, bool $includeDomain): string {
return ($includeDomain ? 'https://mc.edgii.net' : '') . self::TEXTURES_DIR . '/' . $hash . '.png';
}
public function deleteLocalFileMaybe(string $hash): void {
$path = $this->getLocalPath($hash);
if(is_file($path) && !$this->checkHash($hash))
unlink($path);
}
public function verifyRequest($response, $request) {
if(!$this->authInfo->success)
return 403;
try {
$this->linkInfo = $this->accountLinks->getLink(userInfo: $this->authInfo->user_id);
} catch(RuntimeException $ex) {
$response->redirect('/clients');
return true;
}
if($request->getMethod() === 'POST') {
if(!$request->isFormContent())
return 400;
$body = $request->getContent();
if(!$body->hasParam('csrfp') || !$this->csrfp->verifyToken((string)$body->getParam('csrfp')))
return 403;
}
}
private const SKINS_ERRORS = [
'skin' => [
'model' => 'Invalid model selected.',
'size' => 'Skins may not be larger than 512KiB',
'format' => 'Uploaded file was not an acceptable image.',
],
'cape' => [
'size' => 'Capes may not be larger than 256KiB',
'format' => 'Uploaded file was not an acceptable image.',
],
];
public function getSkins($response, $request) {
$errorCode = (string)$request->getParam('error');
if($errorCode !== '') {
$errorCode = explode(':', $errorCode, 2);
if(count($errorCode) === 2
&& array_key_exists($errorCode[0], self::SKINS_ERRORS)
&& array_key_exists($errorCode[1], self::SKINS_ERRORS[$errorCode[0]]))
$this->templating->addVars([
'error' => [
'section' => $errorCode[0],
'code' => $errorCode[1],
'message' => self::SKINS_ERRORS[$errorCode[0]][$errorCode[1]],
],
]);
}
$skinInfo = $this->skins->getSkin($this->linkInfo);
$skinPath = $skinInfo === null ? null : $this->getRemotePath($skinInfo->getHash(), false);
$capeInfo = $this->capes->getCape($this->linkInfo);
$capePath = $capeInfo === null ? null : $this->getRemotePath($capeInfo->getHash(), false);
return $this->templating->render('skins/index', [
'skin' => $skinInfo,
'skin_path' => $skinPath,
'cape' => $capeInfo,
'cape_path' => $capePath,
]);
}
public function postUploadSkin($response, $request) {
$body = $request->getContent();
if(!$body->hasUploadedFile('texture'))
return 400;
$texture = $body->getUploadedFile('texture');
$model = (string)$body->getParam('model');
if(!in_array($model, Skins::MODELS)) {
$response->redirect('/skins?error=skin:model');
return;
}
if($texture->getSize() > 512000) {
$response->redirect('/skins?error=skin:size');
return;
}
$skinInfo = $this->skins->getSkin($this->linkInfo);
$tmpPath = $texture->getLocalFileName();
$hasNewFile = is_file($tmpPath);
if($hasNewFile) {
try {
$imagick = new Imagick($tmpPath);
$imagick->setImageFormat('png');
$imagick->setBackgroundColor(new ImagickPixel('transparent'));
$imagick->setImageExtent(64, $imagick->getImageHeight() < 64 ? 32 : 64);
$imagick->stripImage();
$imagick->writeImage();
$imagick->destroy();
} catch(ImagickException $ex) {
$response->redirect('/skins?error=skin:format');
return;
}
$hash = hash_file('sha256', $tmpPath);
$localPath = $this->getLocalPath($hash);
} else {
$hash = $skinInfo->getHash();
}
try {
try {
// apply new skin
if($hasNewFile && !is_file($localPath))
$texture->moveTo($localPath);
$this->skins->updateSkin($this->linkInfo, $hash, $model);
} finally {
// see about deleting the old one
if($skinInfo !== null)
$this->deleteLocalFileMaybe($skinInfo->getHash());
}
} finally {
// try to delete new one if something went awry
if($hasNewFile)
$this->deleteLocalFileMaybe($hash);
}
$response->redirect('/skins');
}
public function postDeleteSkin($response) {
$skinInfo = $this->skins->getSkin($this->linkInfo);
if($skinInfo !== null) {
$this->skins->deleteSkin(userInfo: $this->linkInfo);
$this->deleteLocalFileMaybe($skinInfo->getHash());
}
$response->redirect('/skins');
}
public function postUploadCape($response, $request) {
$body = $request->getContent();
if(!$body->hasUploadedFile('texture'))
return 400;
$texture = $body->getUploadedFile('texture');
if($texture->getSize() > 256000) {
$response->redirect('/skins?error=cape:size');
return;
}
$tmpPath = $texture->getLocalFileName();
try {
$imagick = new Imagick($tmpPath);
$imagick->setImageFormat('png');
$imagick->setBackgroundColor(new ImagickPixel('transparent'));
$imagick->setImageExtent(64, 32);
$imagick->stripImage();
$imagick->writeImage();
$imagick->destroy();
} catch(ImagickException $ex) {
$response->redirect('/skins?error=cape:format');
return;
}
$hash = hash_file('sha256', $tmpPath);
$localPath = $this->getLocalPath($hash);
try {
// get previous cape
$capeInfo = $this->capes->getCape($this->linkInfo);
try {
// apply new cape
if(!is_file($localPath))
$texture->moveTo($localPath);
$this->capes->updateCape($this->linkInfo, $hash);
} finally {
// see about deleting the old one
if($capeInfo !== null)
$this->deleteLocalFileMaybe($capeInfo->getHash());
}
} finally {
// try to delete new one if something went awry
$this->deleteLocalFileMaybe($hash);
}
$response->redirect('/skins');
}
public function postDeleteCape($response) {
$capeInfo = $this->capes->getCape($this->linkInfo);
if($capeInfo !== null) {
$this->capes->deleteCape(userInfo: $this->linkInfo);
$this->deleteLocalFileMaybe($capeInfo->getHash());
}
$response->redirect('/skins');
}
public function postImport($response, $request) {
$body = $request->getContent();
$userAgent = $request->getHeaderLine('User-Agent');
$resolveUUID = MojangInterop::getMinecraftUUID((string)$body->getParam('username'), $userAgent);
if($resolveUUID !== null) {
$profileInfo = MojangInterop::getSessionMinecraftProfile($resolveUUID->id, $userAgent);
if(isset($profileInfo->properties))
foreach($profileInfo->properties as $prop) {
if($prop->name === 'textures') {
$textureInfo = json_decode(base64_decode($prop->value));
if(!isset($textureInfo->textures))
break;
if(isset($textureInfo->textures->SKIN)) {
$url = $textureInfo->textures->SKIN->url;
$model = 'classic';
if(isset($textureInfo->textures->SKIN->metadata) && isset($textureInfo->textures->SKIN->metadata->model))
$model = $textureInfo->textures->SKIN->metadata->model;
$hash = null;
$tmpFile = sys_get_temp_dir() . '/mc-import-skin-' . XString::random(8) . '.png';
try {
file_put_contents($tmpFile, file_get_contents($url));
$hash = hash_file('sha256', $tmpFile);
$localPath = $this->getLocalPath($hash);
rename($tmpFile, $localPath);
$this->skins->updateSkin($this->linkInfo, $hash, $model);
} finally {
if(is_file($tmpFile))
unlink($tmpFile);
if($hash !== null)
$this->deleteLocalFileMaybe($hash);
}
}
if(isset($textureInfo->textures->CAPE)) {
$url = $textureInfo->textures->CAPE->url;
$hash = null;
$tmpFile = sys_get_temp_dir() . '/mc-import-cape-' . XString::random(8) . '.png';
try {
file_put_contents($tmpFile, file_get_contents($url));
$hash = hash_file('sha256', $tmpFile);
$localPath = $this->getLocalPath($hash);
rename($tmpFile, $localPath);
$this->capes->updateCape($this->linkInfo, $hash);
} finally {
if(is_file($tmpFile))
unlink($tmpFile);
if($hash !== null)
$this->deleteLocalFileMaybe($hash);
}
}
break;
}
}
}
$response->redirect('/skins');
}
public function getSessionMinecraftProfile($response, $request, string $id) {
try {
$uuid = Uuid::fromString($id);
} catch(InvalidArgumentException $ex) {
$response->setStatusCode(400);
return [
'path' => sprintf('/session/minecraft/profile/%s', $id),
'errorMessage' => sprintf('Not a valid UUID: %s', $id),
];
}
if(MojangInterop::isMojangId($uuid))
return MojangInterop::proxySessionMinecraftProfile($response, $request, $id);
$response->setCacheControl('max-age=30');
if(!MojangInterop::isOfflineId($uuid))
return 204;
try {
$linkInfo = $this->accountLinks->getLink(uuid: $uuid);
} catch(RuntimeException $ex) {
return 204;
}
$textures = [];
$skinInfo = $this->skins->getSkin($linkInfo);
if($skinInfo !== null) {
$texture = ['url' => $this->getRemotePath($skinInfo->getHash(), true)];
if(!$skinInfo->isClassic())
$texture['metadata'] = ['model' => $skinInfo->getModel()];
$textures['SKIN'] = $texture;
}
$capeInfo = $this->capes->getCape($linkInfo);
if($capeInfo !== null)
$textures['CAPE'] = ['url' => $this->getRemotePath($capeInfo->getHash(), true)];
$profileId = (string)$uuid->getHex();
$profileName = $linkInfo->getName();
return [
'id' => $profileId,
'name' => $profileName,
'profileActions' => [],
'properties' => [
[
'name' => 'textures',
'value' => base64_encode(json_encode([
'timestamp' => MojangInterop::currentTime(),
'profileId' => $profileId,
'profileName' => $profileName,
'textures' => $textures,
], JSON_UNESCAPED_SLASHES)),
],
],
];
}
public function getUsersMinecraftProfile($response, $request, string $name) {
try {
$linkInfo = $this->accountLinks->getLink(name: $name);
} catch(RuntimeException $ex) {
$response->setStatusCode(404);
return [
'path' => sprintf('/users/profiles/minecraft/%s', $name),
'errorMessage' => 'Couldn\'t find any profile with that name',
];
}
return [
'id' => (string)$linkInfo->getUUID()->getHex(),
'name' => $linkInfo->getName(),
];
}
}

38
src/UserInfo.php Normal file
View file

@ -0,0 +1,38 @@
<?php
namespace Mince;
use Index\Colour\Colour;
use Index\Colour\ColourRGB;
use Index\Data\IDbResult;
class UserInfo {
private string $id;
private string $name;
private ?int $colour;
public function __construct(IDbResult $result) {
$this->id = $result->getString(0);
$this->name = $result->getString(1);
$this->colour = $result->isNull(2) ? null : $result->getInteger(2);
}
public function getId(): string {
return $this->id;
}
public function getName(): string {
return $this->name;
}
public function hasColour(): bool {
return $this->colour !== null;
}
public function getColourRaw(): ?int {
return $this->colour;
}
public function getColour(): Colour {
return $this->colour === null ? Colour::none() : ColourRGB::fromRawRGB($this->colour);
}
}

40
src/Users.php Normal file
View file

@ -0,0 +1,40 @@
<?php
namespace Mince;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
class Users {
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->cache = new DbStatementCache($dbConn);
}
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 users (user_id, user_name, user_colour) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE user_name = ?, user_colour = ?');
$stmt->addParameter(1, $authInfo->user_id);
$stmt->addParameter(2, $authInfo->username);
$stmt->addParameter(3, $userColourFixed);
$stmt->addParameter(4, $authInfo->username);
$stmt->addParameter(5, $userColourFixed);
$stmt->execute();
}
public function getUser(string $userId): UserInfo {
$stmt = $this->cache->get('SELECT user_id, user_name, user_colour FROM users WHERE user_id = ?');
$stmt->addParameter(1, $userId);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('User info not found.');
return new UserInfo($result);
}
}

56
src/VerificationInfo.php Normal file
View file

@ -0,0 +1,56 @@
<?php
namespace Mince;
use Index\DateTime;
use Index\Data\IDbResult;
use Index\Net\IPAddress;
use Ramsey\Uuid\Uuid;
use Ramsey\Uuid\UuidInterface;
class VerificationInfo {
private string $code;
private string $uuid;
private string $name;
private string $addr;
private int $created;
public function __construct(IDbResult $result) {
$this->code = $result->getString(0);
$this->uuid = $result->getString(1);
$this->name = $result->getString(2);
$this->addr = $result->getString(3);
$this->created = $result->getInteger(4);
}
public function getCode(): string {
return $this->code;
}
public function getUUIDRaw(): string {
return $this->uuid;
}
public function getUUID(): UuidInterface {
return Uuid::fromBytes($this->uuid);
}
public function getName(): string {
return $this->name;
}
public function getAddressRaw(): string {
return $this->addr;
}
public function getAddress(): IPAddress {
return IPAddress::parse($this->addr);
}
public function getCreatedTime(): int {
return $this->created;
}
public function getCreatedAt(): DateTime {
return DateTime::fromUnixTimeSeconds($this->created);
}
}

110
src/Verifications.php Normal file
View file

@ -0,0 +1,110 @@
<?php
namespace Mince;
use InvalidArgumentException;
use RuntimeException;
use Index\Data\DbStatementCache;
use Index\Data\IDbConnection;
use Index\Net\IPAddress;
use Index\Serialisation\Base32;
use Ramsey\Uuid\UuidInterface;
class Verifications {
private IDbConnection $dbConn;
private DbStatementCache $cache;
public function __construct(IDbConnection $dbConn) {
$this->dbConn = $dbConn;
$this->cache = new DbStatementCache($dbConn);
}
public static function generateCode(): string {
return Base32::encode(random_bytes(6));
}
public function prune(): void {
$this->dbConn->execute('DELETE FROM verifications WHERE verify_created < NOW() - INTERVAL 15 MINUTE');
}
public function getVerification(
?string $code = null,
UuidInterface|string|null $uuid = null,
IPAddress|string|null $remoteAddr = null
): VerificationInfo {
$hasCode = $code !== null;
$hasUuid = $uuid !== null;
$hasRemoteAddr = $remoteAddr !== null;
$values = [];
$query = 'SELECT verify_code, verify_uuid, verify_name, INET6_NTOA(verify_addr), UNIX_TIMESTAMP(verify_created) FROM verifications';
if($hasCode) {
$query .= ' WHERE verify_code = ?';
$values[] = $code;
} else {
$args = 0;
if($hasUuid) {
if($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
++$args;
$query .= ' WHERE verify_uuid = ?';
$values[] = $uuid;
}
if($hasRemoteAddr) {
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$query .= sprintf(' %s verify_addr = INET6_ATON(?)', ++$args > 1 ? 'AND' : 'WHERE');
$values[] = $remoteAddr;
}
}
if(empty($values))
throw new InvalidArgumentException('Not enough arguments specified.');
$args = 0;
$stmt = $this->cache->get($query);
foreach($values as $value)
$stmt->addParameter(++$args, $value);
$stmt->execute();
$result = $stmt->getResult();
if(!$result->next())
throw new RuntimeException('Verification info not found.');
return new VerificationInfo($result);
}
public function createVerification(
UuidInterface|string $uuid,
string $name,
IPAddress|string $remoteAddr
): string {
if($uuid instanceof UuidInterface)
$uuid = $uuid->getBytes();
if($remoteAddr instanceof IPAddress)
$remoteAddr = (string)$remoteAddr;
$code = self::generateCode();
$stmt = $this->cache->get('REPLACE INTO verifications (verify_code, verify_uuid, verify_name, verify_addr) VALUES (?, ?, ?, INET6_ATON(?))');
$stmt->addParameter(1, $code);
$stmt->addParameter(2, $uuid);
$stmt->addParameter(3, $name);
$stmt->addParameter(4, $remoteAddr);
$stmt->execute();
return $code;
}
public function deleteVerification(VerificationInfo|string $code): void {
if($code instanceof VerificationInfo)
$code = $code->getCode();
$stmt = $this->cache->get('DELETE FROM verifications WHERE verify_code = ?');
$stmt->addParameter(1, $code);
$stmt->execute();
}
}

View file

@ -0,0 +1,131 @@
{% extends 'clients/master.twig' %}
{% block content %}
<div class="section accheader">
<h1>Clients</h1>
<p>On this page you can manage what Minecraft username is linked to your account as well as what clients are authorised to use that username. Please check the list of authorised clients from time to time if you know your IP address changes frequently to prevent any unauthorised access.</p>
</div>
{% if link is not defined %}
<div class="section acclink">
<h2>Link a Minecraft account</h2>
<p>This will associate a Minecraft username with your Flashii ID. You may only have one linked at a time. In order to obtain a link code, connect to one of the Minecraft servers.</p>
<form method="post" action="/clients/link">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<label>
<div class="label-header">Link code</div>
<div class="label-input"><input type="text" name="code" value="" minlength="10" maxlength="10" pattern="^[A-Za-z0-9]*$" spellcheck="false"></div>
</label>
<input type="submit" value="Link account">
</form>
</div>
{% else %}
<div class="section accipaddr">
<h2>Your IP Address</h2>
<p>Use this to verify the clients list below. The Authorise button only becomes available once your IP address is visible here.</p>
<ul>
<li>Your IPv4 address is: <span class="js-ipv4">loading...</span></li>
</ul>
</div>
<div class="section accclients">
<h2>Clients</h2>
<p>This list contains the list of both authorised and pending clients. Only authorise clients you recognise and take care to remove old ones you don't use anymore.</p>
{% if clients is empty %}
<p><em>You currently don't have any pending or authorised clients.</em></p>
{% else %}
<table>
<thead>
<tr>
<th>IP Address</th>
<th>Requested</th>
<th>Granted</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for client in clients %}
<tr>
<td class="ipaddr">
<span data-addr="{{ client.addressRaw }}">{{ client.addressRaw }}</span>
</td>
<td class="request">
{{ client.requestedTime|date('Y-m-d H:i:s T') }}
</td>
<td class="granted">
{{ client.isPending ? 'pending' : client.grantedTime|date('Y-m-d H:i:s T') }}
</td>
<td class="used">
{{ client.isUsed ? client.lastUsedTime|date('Y-m-d H:i:s T') : 'unused' }}
</td>
<td class="actions">
{% if client.isPending %}
<form method="post" action="/clients/authorise">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="hidden" name="auth" value="{{ client.id }}">
<input class="action action-authorise js-authorise-button" type="submit" value="Authorise" disabled onclick="return confirm('Are you sure?');">
</form>
{% endif %}
<form method="post" action="/clients/deauthorise">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="hidden" name="auth" value="{{ client.id }}">
<input class="action action-deauthorise" type="submit" value="{% if client.isPending %}Deny{% else %}Deauthorise{% endif %}">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="section accmegadeauth">
<h2>Crowd Control</h2>
<p>Provided for those who prefer the nuclear option. Pressing the first button will deauthorise all clients, the other one will only remove pending ones.</p>
<form method="post" action="/clients/deauthorise">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<button class="form-btn-red" name="auth" value="all">Deauthorise all clients</button>
<button class="form-btn-green" name="auth" value="pending">Deny pending clients</button>
</form>
</div>
<div class="section accunlink">
<h2>Linked Minecraft account</h2>
<p>This is the Minecraft account currently associated with your Flashii ID. Revoking revoke your access to the servers. <strong>If you're planning on changing your username, please keep in mind that your stats and inventory on the servers WILL NOT carry over automatically.</strong></p>
<p>Your account has been linked with <b>{{ link.name }}</b> since <b>{{ link.createdTime|date('Y-m-d H:i:s T') }}</b>.</p>
<form method="post" action="/clients/unlink">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="submit" value="Unlink account">
</form>
</div>
<script>
window.addEventListener('DOMContentLoaded', function() {
// it's possible that the site was loaded over IPv6
const ipv4field = document.querySelector('.js-ipv4');
const xhr = new XMLHttpRequest;
xhr.addEventListener('load', function() {
ipv4field.style.fontWeight = '700';
ipv4field.textContent = xhr.responseText;
const addrs = document.querySelectorAll('[data-addr="' + xhr.responseText + '"]');
for(const addr of addrs)
addr.style.textDecoration = 'underline';
const buttons = document.querySelectorAll('.js-authorise-button');
for(const button of buttons)
button.disabled = false;
});
xhr.addEventListener('error', function() {
ipv4field.style.color = 'red';
ipv4field.textContent = 'Failed to connect to ipv4.flash.moe, are you blocking it?';
});
xhr.open('GET', '//ipv4.flash.moe');
xhr.send();
});
</script>
{% endif %}
{% endblock %}

View file

@ -0,0 +1 @@
{% extends 'master.twig' %}

18
templates/downloads.twig Normal file
View file

@ -0,0 +1,18 @@
{% extends 'master.twig' %}
{% block content %}
<div class="section downloads-header">
<h1>Downloads</h1>
<p>This page contains relevant downloads for our servers.</p>
</div>
<div class="section downloads-item">
<h2>Flashii Extensions</h2>
<p>This as a mod built on <a href="https://fabricmc.net/" target="_blank" rel="noopener">Fabric</a> to alter the server your Minecraft client uses to request skins from Mojang's to ours.{# This mod is likely why you're on this page. <-- add this is there's more downloads ever #}</p>
<ul>
<li><a href="/dl/flashii-extensions-1.0.0.jar">Download Flashii Extensions 1.0.0</a></li>
<li><a href="https://git.flash.moe/flashii/mcexts" target="_blank" rel="noopener">Source Code</a></li>
</ul>
</div>
{% endblock %}

View file

@ -8,7 +8,7 @@
</div>
{% endif %}
{% if not auth.success %}
{% if not is_authed %}
<div class="section">
<h2>You must be logged in to use this website!</h2>
<p>This website allows you to whitelist yourself on our Minecraft servers, for which you need to be logged in.</p>
@ -18,7 +18,7 @@
{% if auth.mc_whitelisted < 1 %}
<div class="section whitelist">
<h2>Add to Whitelist</h2>
<p>This will give you access to the server.</p>
<p>This will give you access to the server. <strong>The whitelist is being removed in favour of an authentication plugin, go to the <a href="/clients">clients page</a> for more info.</strong></p>
<form method="post" action="/whitelist/add">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<label>

View file

@ -13,12 +13,19 @@
<div class="header-logo">
<a href="/"><img src="/assets/weblogo.png" alt="Flashii Minecraft"></a>
</div>
<div class="header-fat"></div>
<div class="header-menu">
<a href="/">Home</a>
<a href="/downloads">Downloads</a>
{% if is_authed %}
<a href="/clients">Clients</a>
<a href="/skins">Skins</a>
{% endif %}
</div>
<div class="header-user">
{% if auth.success %}
Logged in as {{ auth.username }}
{% if is_authed %}
Logged in as <span style="color: {{ user.colour }}">{{ user.name }}</span>
{% else %}
<a href="{{ global.loginUrl }}">Log in</a>
<a href="/login">Log in</a>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,84 @@
{% extends 'skins/master.twig' %}
{% block content %}
<div class="section skins-header">
<h1>Skins</h1>
<p>Because our servers run in offline mode with our own authentication plugin, skins are downloaded from Mojang's servers. To make up for this we also have a client side mod that substitutes the skin and cape server with our own. You can find the mod on the <a href="/downloads">downloads page</a>. Please keep your textures clean.</p>
</div>
<div class="section skin">
<h2>Changing your Skin</h2>
<p>Change your in-game appearance. You can change the model type of your skin by hitting upload without selecting a new image.</p>
{% if error is defined and error.section == 'skin' %}
<p style="color: red;">{{ error.message }}</p>
{% endif %}
<div class="skins-form">
<form method="post" action="/skins/upload-skin" enctype="multipart/form-data">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="file" name="texture">
<select name="model">
{% set selected_model = skin.model|default() %}
<option value="classic"{% if selected_model == 'classic' %} selected{% endif %}>Classic (Steve, 4px)</option>
<option value="slim"{% if selected_model == 'slim' %} selected{% endif %}>Slim (Alex, 3px)</option>
</select>
<input type="submit" value="Upload Skin" class="skins-form-upload">
</form>
{% if skin is not null %}
<form method="post" action="/skins/delete-skin">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="submit" value="Delete Skin" class="skins-form-delete">
</form>
{% endif %}
</div>
{% if skin_path is not null %}
<div class="skins-preview">
<img src="{{ skin_path }}" alt="Your skin">
</div>
{% endif %}
</div>
<div class="section cape">
<h2>Changing your Cape</h2>
<p>Change your cape.</p>
{% if error is defined and error.section == 'cape' %}
<p style="color: red;">{{ error.message }}</p>
{% endif %}
<div class="skins-form">
<form method="post" action="/skins/upload-cape" enctype="multipart/form-data">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="file" name="texture">
<input type="submit" value="Upload Cape" class="skins-form-upload">
</form>
{% if cape is not null %}
<form method="post" action="/skins/delete-cape">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<input type="submit" value="Delete Cape" class="skins-form-delete">
</form>
{% endif %}
</div>
{% if cape_path is not null %}
<div class="skins-preview">
<img src="{{ cape_path }}" alt="Your cape">
</div>
{% endif %}
</div>
<div class="section skinimport">
<h2>Import from Mojang account</h2>
<p>Import skin and cape textures from a Mojang Minecraft account.</p>
<form method="post" action="/skins/import">
<input type="hidden" name="csrfp" value="{{ csrfp }}">
<label>
<div class="label-header">Minecraft Username</div>
<div class="label-input"><input type="text" name="username" spellcheck="false"></div>
</label>
<input type="submit" value="Import" class="form-btn-green">
</form>
</div>
{% endblock %}

View file

@ -0,0 +1 @@
{% extends 'master.twig' %}

View file

@ -4,8 +4,6 @@ use Mince\Whitelist;
require_once __DIR__ . '/../mince.php';
// rewrite this to use the database shit
echo 'Syncing server whitelists...' . PHP_EOL;
$rInfo = $remote->getInfo();