diff --git a/.gitignore b/.gitignore index 58075ac..0f1dc5e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /config/config.ini /public/robots.txt /vendor +/public/textures diff --git a/composer.json b/composer.json index 1281136..a233477 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,8 @@ "prefer-stable": true, "require": { "flashwave/index": "*", - "twig/twig": "^3.7" + "twig/twig": "^3.7", + "ramsey/uuid": "^4.7" }, "autoload": { "classmap": [ diff --git a/composer.lock b/composer.lock index 20695c5..565cd22 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/2023_08_22_231052_create_new_tables.php b/database/2023_08_22_231052_create_new_tables.php new file mode 100644 index 0000000..171066d --- /dev/null +++ b/database/2023_08_22_231052_create_new_tables.php @@ -0,0 +1,100 @@ +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 + '); + } +} diff --git a/public/dl/flashii-extensions-1.0.0.jar b/public/dl/flashii-extensions-1.0.0.jar new file mode 100644 index 0000000..4acea37 Binary files /dev/null and b/public/dl/flashii-extensions-1.0.0.jar differ diff --git a/public/index.php b/public/index.php index eb44953..a03b5d3 100644 --- a/public/index.php +++ b/public/index.php @@ -1,7 +1,6 @@ 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(); diff --git a/public/mince.css b/public/mince.css index 3e0e59d..cd70426 100644 --- a/public/mince.css +++ b/public/mince.css @@ -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; +} diff --git a/src/AccountLinkInfo.php b/src/AccountLinkInfo.php new file mode 100644 index 0000000..2c9232c --- /dev/null +++ b/src/AccountLinkInfo.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/src/AccountLinks.php b/src/AccountLinks.php new file mode 100644 index 0000000..2ae4504 --- /dev/null +++ b/src/AccountLinks.php @@ -0,0 +1,122 @@ +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(); + } +} diff --git a/src/AuthorisationInfo.php b/src/AuthorisationInfo.php new file mode 100644 index 0000000..61a9808 --- /dev/null +++ b/src/AuthorisationInfo.php @@ -0,0 +1,82 @@ +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); + } +} diff --git a/src/Authorisations.php b/src/Authorisations.php new file mode 100644 index 0000000..ca37da0 --- /dev/null +++ b/src/Authorisations.php @@ -0,0 +1,182 @@ +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(); + } +} diff --git a/src/CapeInfo.php b/src/CapeInfo.php new file mode 100644 index 0000000..8c389be --- /dev/null +++ b/src/CapeInfo.php @@ -0,0 +1,33 @@ +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); + } +} diff --git a/src/Capes.php b/src/Capes.php new file mode 100644 index 0000000..66fcf88 --- /dev/null +++ b/src/Capes.php @@ -0,0 +1,84 @@ +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(); + } +} diff --git a/src/ClientsRoutes.php b/src/ClientsRoutes.php new file mode 100644 index 0000000..22b14db --- /dev/null +++ b/src/ClientsRoutes.php @@ -0,0 +1,145 @@ +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'); + } +} diff --git a/src/HomeRoutes.php b/src/HomeRoutes.php index 0019997..8f9afe9 100644 --- a/src/HomeRoutes.php +++ b/src/HomeRoutes.php @@ -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'); + } } diff --git a/src/MojangInterop.php b/src/MojangInterop.php new file mode 100644 index 0000000..e8ba0fb --- /dev/null +++ b/src/MojangInterop.php @@ -0,0 +1,142 @@ +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; + } +} diff --git a/src/RpcRoutes.php b/src/RpcRoutes.php new file mode 100644 index 0000000..1ee5f30 --- /dev/null +++ b/src/RpcRoutes.php @@ -0,0 +1,149 @@ +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, + ]); + } +} diff --git a/src/SkinInfo.php b/src/SkinInfo.php new file mode 100644 index 0000000..1b238c5 --- /dev/null +++ b/src/SkinInfo.php @@ -0,0 +1,43 @@ +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); + } +} diff --git a/src/Skins.php b/src/Skins.php new file mode 100644 index 0000000..5a8cf2a --- /dev/null +++ b/src/Skins.php @@ -0,0 +1,89 @@ +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(); + } +} diff --git a/src/SkinsRoutes.php b/src/SkinsRoutes.php new file mode 100644 index 0000000..2efc0d1 --- /dev/null +++ b/src/SkinsRoutes.php @@ -0,0 +1,400 @@ +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(), + ]; + } +} diff --git a/src/UserInfo.php b/src/UserInfo.php new file mode 100644 index 0000000..204b307 --- /dev/null +++ b/src/UserInfo.php @@ -0,0 +1,38 @@ +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); + } +} diff --git a/src/Users.php b/src/Users.php new file mode 100644 index 0000000..5b5ae9f --- /dev/null +++ b/src/Users.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/src/VerificationInfo.php b/src/VerificationInfo.php new file mode 100644 index 0000000..f175242 --- /dev/null +++ b/src/VerificationInfo.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/src/Verifications.php b/src/Verifications.php new file mode 100644 index 0000000..b017fb5 --- /dev/null +++ b/src/Verifications.php @@ -0,0 +1,110 @@ +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(); + } +} diff --git a/templates/clients/index.twig b/templates/clients/index.twig new file mode 100644 index 0000000..7591f51 --- /dev/null +++ b/templates/clients/index.twig @@ -0,0 +1,131 @@ +{% extends 'clients/master.twig' %} + +{% block content %} +
+

Clients

+

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.

+
+ + {% if link is not defined %} + + {% else %} +
+

Your IP Address

+

Use this to verify the clients list below. The Authorise button only becomes available once your IP address is visible here.

+ +
+ +
+

Clients

+

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.

+ + {% if clients is empty %} +

You currently don't have any pending or authorised clients.

+ {% else %} + + + + + + + + + + + + {% for client in clients %} + + + + + + + + + {% endfor %} + +
IP AddressRequestedGrantedLast UsedActions
+ {{ client.addressRaw }} + + {{ client.requestedTime|date('Y-m-d H:i:s T') }} + + {{ client.isPending ? 'pending' : client.grantedTime|date('Y-m-d H:i:s T') }} + + {{ client.isUsed ? client.lastUsedTime|date('Y-m-d H:i:s T') : 'unused' }} + + {% if client.isPending %} +
+ + + +
+ {% endif %} +
+ + + +
+
+ {% endif %} +
+ +
+

Crowd Control

+

Provided for those who prefer the nuclear option. Pressing the first button will deauthorise all clients, the other one will only remove pending ones.

+
+ + + +
+
+ + + + + {% endif %} +{% endblock %} diff --git a/templates/clients/master.twig b/templates/clients/master.twig new file mode 100644 index 0000000..fc530f2 --- /dev/null +++ b/templates/clients/master.twig @@ -0,0 +1 @@ +{% extends 'master.twig' %} diff --git a/templates/downloads.twig b/templates/downloads.twig new file mode 100644 index 0000000..74bed8d --- /dev/null +++ b/templates/downloads.twig @@ -0,0 +1,18 @@ +{% extends 'master.twig' %} + +{% block content %} +
+

Downloads

+

This page contains relevant downloads for our servers.

+
+ +
+

Flashii Extensions

+

This as a mod built on Fabric 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 #}

+ + +
+{% endblock %} diff --git a/templates/index.twig b/templates/index.twig index 1f5a4bf..1840d42 100644 --- a/templates/index.twig +++ b/templates/index.twig @@ -8,7 +8,7 @@ {% endif %} - {% if not auth.success %} + {% if not is_authed %}

You must be logged in to use this website!

This website allows you to whitelist yourself on our Minecraft servers, for which you need to be logged in.

@@ -18,7 +18,7 @@ {% if auth.mc_whitelisted < 1 %}

Add to Whitelist

-

This will give you access to the server.

+

This will give you access to the server. The whitelist is being removed in favour of an authentication plugin, go to the clients page for more info.

diff --git a/templates/skins/index.twig b/templates/skins/index.twig new file mode 100644 index 0000000..daf03a5 --- /dev/null +++ b/templates/skins/index.twig @@ -0,0 +1,84 @@ +{% extends 'skins/master.twig' %} + +{% block content %} +
+

Skins

+

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 downloads page. Please keep your textures clean.

+
+ +
+

Changing your Skin

+

Change your in-game appearance. You can change the model type of your skin by hitting upload without selecting a new image.

+ + {% if error is defined and error.section == 'skin' %} +

{{ error.message }}

+ {% endif %} + +
+ + + + + + + {% if skin is not null %} +
+ + +
+ {% endif %} +
+ + {% if skin_path is not null %} +
+ Your skin +
+ {% endif %} +
+ +
+

Changing your Cape

+

Change your cape.

+ + {% if error is defined and error.section == 'cape' %} +

{{ error.message }}

+ {% endif %} + +
+
+ + + +
+ {% if cape is not null %} +
+ + +
+ {% endif %} +
+ + {% if cape_path is not null %} +
+ Your cape +
+ {% endif %} +
+ +
+

Import from Mojang account

+

Import skin and cape textures from a Mojang Minecraft account.

+
+ + + +
+
+{% endblock %} diff --git a/templates/skins/master.twig b/templates/skins/master.twig new file mode 100644 index 0000000..fc530f2 --- /dev/null +++ b/templates/skins/master.twig @@ -0,0 +1 @@ +{% extends 'master.twig' %} diff --git a/tools/sync b/tools/sync index a3964e9..4a4fd95 100755 --- a/tools/sync +++ b/tools/sync @@ -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();