This commit is contained in:
ErickSkrauch
2019-04-01 16:04:08 +02:00
commit 7211fbc190
30 changed files with 4562 additions and 0 deletions

265
src/Api.php Normal file
View File

@@ -0,0 +1,265 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang;
use Ely\Mojang\Middleware\ResponseConverterMiddleware;
use Ely\Mojang\Middleware\RetryMiddleware;
use GuzzleHttp\Client as GuzzleClient;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Uri;
use Ramsey\Uuid\Uuid;
class Api {
/**
* @var ClientInterface
*/
private $client;
public function __construct(ClientInterface $client) {
$this->client = $client;
}
/**
* @param callable $handler HTTP handler function to use with the stack. If no
* handler is provided, the best handler for your
* system will be utilized.
*
* @return static
*/
public static function create(callable $handler = null): self {
$stack = HandlerStack::create($handler);
// use after method because middleware executes in reverse order
$stack->after('http_errors', ResponseConverterMiddleware::create(), 'mojang_response_converter');
$stack->push(RetryMiddleware::create(), 'retry');
return new static(new GuzzleClient([
'handler' => $stack,
'timeout' => 10,
]));
}
/**
* @param string $username
* @param int $atTime
*
* @return \Ely\Mojang\Response\ProfileInfo
*
* @throws \Ely\Mojang\Exception\MojangApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url http://wiki.vg/Mojang_API#Username_-.3E_UUID_at_time
*/
public function usernameToUUID(string $username, int $atTime = null): Response\ProfileInfo {
$query = [];
if ($atTime !== null) {
$query['atTime'] = $atTime;
}
$response = $this->getClient()->request('GET', "https://api.mojang.com/users/profiles/minecraft/{$username}", [
'query' => $query,
]);
$data = $this->decode($response->getBody()->getContents());
return Response\ProfileInfo::createFromResponse($data);
}
/**
* @param string $uuid
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws \Ely\Mojang\Exception\MojangApiException
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url http://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape
*/
public function uuidToTextures(string $uuid): Response\ProfileResponse {
$response = $this->getClient()->request('GET', "https://sessionserver.mojang.com/session/minecraft/profile/{$uuid}", [
'query' => [
'unsigned' => false,
],
]);
$body = $this->decode($response->getBody()->getContents());
return new Response\ProfileResponse($body['id'], $body['name'], $body['properties']);
}
/**
* Helper method to exchange username to the corresponding textures.
*
* @param string $username
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws GuzzleException
* @throws \Ely\Mojang\Exception\MojangApiException
*/
public function usernameToTextures(string $username): Response\ProfileResponse {
return $this->uuidToTextures($this->usernameToUUID($username)->getId());
}
/**
* @param string $login
* @param string $password
* @param string $clientToken
*
* @return \Ely\Mojang\Response\AuthenticateResponse
*
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url https://wiki.vg/Authentication#Authenticate
*/
public function authenticate(
string $login,
string $password,
string $clientToken = null
): Response\AuthenticateResponse {
if ($clientToken === null) {
/** @noinspection PhpUnhandledExceptionInspection */
$clientToken = Uuid::uuid4()->toString();
}
$response = $this->getClient()->request('POST', 'https://authserver.mojang.com/authenticate', [
'json' => [
'username' => $login,
'password' => $password,
'clientToken' => $clientToken,
'requestUser' => true,
'agent' => [
'name' => 'Minecraft',
'version' => 1,
],
],
]);
$body = $this->decode($response->getBody()->getContents());
return new Response\AuthenticateResponse(
$body['accessToken'],
$body['clientToken'],
$body['availableProfiles'],
$body['selectedProfile'],
$body['user']
);
}
/**
* @param string $accessToken
*
* @return bool
*
* @throws \GuzzleHttp\Exception\GuzzleException
*
* @url https://wiki.vg/Authentication#Validate
*/
public function validate(string $accessToken): bool {
try {
$response = $this->getClient()->request('POST', 'https://authserver.mojang.com/authenticate', [
'json' => [
'accessToken' => $accessToken,
],
]);
if ($response->getStatusCode() === 204) {
return true;
}
} catch (Exception\ForbiddenException $e) {
// Suppress exception and let it just exit below
}
return false;
}
/**
* @param string $accessToken
* @param string $accountUuid
* @param \Psr\Http\Message\StreamInterface|resource|string $skinContents
* @param bool $isSlim
*
* @throws GuzzleException
*
* @url https://wiki.vg/Mojang_API#Upload_Skin
*/
public function uploadSkin(string $accessToken, string $accountUuid, $skinContents, bool $isSlim): void {
$this->getClient()->request('PUT', "https://api.mojang.com/user/profile/{$accountUuid}/skin", [
'multipart' => [
[
'name' => 'file',
'contents' => $skinContents,
'filename' => 'char.png',
],
[
'name' => 'model',
'contents' => $isSlim ? 'slim' : '',
],
],
'headers' => [
'Authorization' => 'Bearer ' . $accessToken,
],
]);
}
/**
* @param string $accessToken
* @param string $accountUuid
* @param string $serverId
*
* @throws GuzzleException
*
* @url https://wiki.vg/Protocol_Encryption#Client
*/
public function joinServer(string $accessToken, string $accountUuid, string $serverId): void {
$this->getClient()->request('POST', 'https://sessionserver.mojang.com/session/minecraft/join', [
'json' => [
'accessToken' => $accessToken,
'selectedProfile' => $accountUuid,
'serverId' => $serverId,
],
]);
}
/**
* @param string $username
* @param string $serverId
*
* @return \Ely\Mojang\Response\ProfileResponse
*
* @throws \Ely\Mojang\Exception\NoContentException
* @throws GuzzleException
*
* @url https://wiki.vg/Protocol_Encryption#Server
*/
public function hasJoinedServer(string $username, string $serverId): Response\ProfileResponse {
$uri = (new Uri('https://sessionserver.mojang.com/session/minecraft/hasJoined'))
->withQuery(http_build_query([
'username' => $username,
'serverId' => $serverId,
], '', '&', PHP_QUERY_RFC3986));
$request = new Request('GET', $uri);
$response = $this->getClient()->send($request);
$rawBody = $response->getBody()->getContents();
if (empty($rawBody)) {
throw new Exception\NoContentException($request, $response);
}
$body = $this->decode($rawBody);
return new Response\ProfileResponse($body['id'], $body['name'], $body['properties']);
}
/**
* @return ClientInterface
*/
protected function getClient(): ClientInterface {
return $this->client;
}
private function decode(string $response): array {
return json_decode($response, true);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\ClientException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ForbiddenException extends ClientException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct(
'The request was executed with a non-existent or expired access token',
$request,
$response
);
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use Throwable;
interface MojangApiException extends Throwable {
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class NoContentException extends RequestException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct('No data were received in the response.', $request, $response);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Exception;
use GuzzleHttp\Exception\ClientException;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class TooManyRequestsException extends ClientException implements MojangApiException {
public function __construct(RequestInterface $request, ResponseInterface $response) {
parent::__construct(
'The request limit was exceeded. ' .
'Read the documentation for the method requested to find out which RPS is allowed.',
$request,
$response
);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Middleware;
use Ely\Mojang\Exception;
use GuzzleHttp\Promise\PromiseInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ResponseConverterMiddleware {
/**
* @var callable
*/
private $nextHandler;
public function __construct(callable $nextHandler) {
$this->nextHandler = $nextHandler;
}
public function __invoke(RequestInterface $request, array $options): PromiseInterface {
$fn = $this->nextHandler;
/** @var PromiseInterface $promise */
$promise = $fn($request, $options);
return $promise->then(static function($response) use ($request) {
if ($response instanceof ResponseInterface) {
$method = $request->getMethod();
$statusCode = $response->getStatusCode();
if ($method === 'GET' && $statusCode === 204) {
throw new Exception\NoContentException($request, $response);
}
if ($statusCode === 403) {
throw new Exception\ForbiddenException($request, $response);
}
if ($statusCode === 429) {
throw new Exception\TooManyRequestsException($request, $response);
}
}
return $response;
});
}
public static function create(): callable {
return static function(callable $handler): callable {
return new static($handler);
};
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Middleware;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class RetryMiddleware {
public static function create(): callable {
return Middleware::retry([static::class, 'shouldRetry']);
}
public static function shouldRetry(
int $retries,
RequestInterface $request,
?ResponseInterface $response,
?GuzzleException $reason
): bool {
if ($retries >= 2) {
return false;
}
if ($reason instanceof ConnectException) {
return true;
}
if ($response !== null && (int)floor($response->getStatusCode() / 100) === 5) {
return true;
}
return false;
}
}

View File

@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
class AuthenticateResponse {
/**
* @var string
*/
private $accessToken;
/**
* @var string
*/
private $clientToken;
/**
* @var array
*/
private $rawAvailableProfiles;
/**
* @var array
*/
private $rawSelectedProfile;
/**
* @var array
*/
private $rawUser;
public function __construct(
string $accessToken,
string $clientToken,
array $availableProfiles,
array $selectedProfile,
array $user
) {
$this->accessToken = $accessToken;
$this->clientToken = $clientToken;
$this->rawAvailableProfiles = $availableProfiles;
$this->rawSelectedProfile = $selectedProfile;
$this->rawUser = $user;
}
public function getAccessToken(): string {
return $this->accessToken;
}
public function getClientToken(): string {
return $this->clientToken;
}
/**
* @return ProfileInfo[]
*/
public function getAvailableProfiles(): array {
return array_map([ProfileInfo::class, 'createFromResponse'], $this->rawAvailableProfiles);
}
public function getSelectedProfile(): ProfileInfo {
return ProfileInfo::createFromResponse($this->rawSelectedProfile);
}
public function getUser(): AuthenticationResponseUserField {
return new AuthenticationResponseUserField($this->rawUser['id'], $this->rawUser['properties']);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
use Ely\Mojang\Response\Properties\Factory;
class AuthenticationResponseUserField {
private $id;
private $rawProperties;
public function __construct(string $id, array $rawProperties) {
$this->id = $id;
$this->rawProperties = $rawProperties;
}
public function getId(): string {
return $this->id;
}
/**
* @return \Ely\Mojang\Response\Properties\Property[]
*/
public function getProperties(): array {
return array_map([Factory::class, 'createFromProp'], $this->rawProperties);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
class ProfileInfo {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var bool
*/
private $isLegacy;
/**
* @var bool
*/
private $isDemo;
public function __construct(string $id, string $name, bool $isLegacy = false, bool $isDemo = false) {
$this->id = $id;
$this->name = $name;
$this->isLegacy = $isLegacy;
$this->isDemo = $isDemo;
}
public static function createFromResponse(array $response): self {
return new static(
$response['id'],
$response['name'],
$response['legacy'] ?? false,
$response['demo'] ?? false
);
}
/**
* @return string user's uuid without dashes
*/
public function getId(): string {
return $this->id;
}
/**
* @return string username at the current time
*/
public function getName(): string {
return $this->name;
}
/**
* @return bool true means, that account not migrated into Mojang account
*/
public function isLegacy(): bool {
return $this->isLegacy;
}
/**
* @return bool true means, that account now in demo mode (not premium user)
*/
public function isDemo(): bool {
return $this->isDemo;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response;
use Ely\Mojang\Response\Properties\Factory;
class ProfileResponse {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $name;
/**
* @var array
*/
private $props;
public function __construct(string $id, string $name, array $rawProps) {
$this->id = $id;
$this->name = $name;
$this->props = $rawProps;
}
public function getId(): string {
return $this->id;
}
public function getName(): string {
return $this->name;
}
/**
* @return \Ely\Mojang\Response\Properties\Property[]
*/
public function getProps(): array {
return array_map([Factory::class, 'createFromProp'], $this->props);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class Factory {
private static $MAP = [
'textures' => TexturesProperty::class,
];
public static function createFromProp(array $prop): Property {
$name = $prop['name'];
if (isset(static::$MAP[$name])) {
$className = static::$MAP[$name];
return new $className($prop);
}
return new Property($prop);
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class Property {
/**
* @var string
*/
public $name;
/**
* @var string
*/
public $value;
public function __construct(array $prop) {
$this->name = $prop['name'];
$this->value = $prop['value'];
}
public function getName(): string {
return $this->name;
}
public function getValue(): string {
return $this->value;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesProperty extends Property {
/**
* @var string|null
*/
private $signature;
public function __construct(array $prop) {
parent::__construct($prop);
$this->signature = $prop['signature'] ?? null;
}
public function getTextures(): TexturesPropertyValue {
return TexturesPropertyValue::createFromRawTextures($this->value);
}
public function getSignature(): ?string {
return $this->signature;
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValue {
/**
* @var string
*/
private $id;
/**
* @var string
*/
private $username;
/**
* @var array
*/
private $textures;
/**
* @var int
*/
private $timestamp;
/**
* @var bool
*/
private $signatureRequired;
public function __construct(
string $profileId,
string $profileName,
array $textures,
int $timestamp,
bool $signatureRequired = false
) {
$this->id = $profileId;
$this->username = $profileName;
$this->textures = $textures;
$this->timestamp = (int)floor($timestamp / 1000);
$this->signatureRequired = $signatureRequired;
}
public static function createFromRawTextures(string $rawTextures): self {
$decoded = json_decode(base64_decode($rawTextures), true);
return new static(
$decoded['profileId'],
$decoded['profileName'],
$decoded['textures'],
$decoded['timestamp'],
$decoded['signatureRequired'] ?? false
);
}
public function getProfileId(): string {
return $this->id;
}
public function getProfileName(): string {
return $this->username;
}
public function getTimestamp(): int {
return $this->timestamp;
}
public function isSignatureRequired(): bool {
return $this->signatureRequired;
}
public function getSkin(): ?TexturesPropertyValueSkin {
if (!isset($this->textures['SKIN'])) {
return null;
}
return TexturesPropertyValueSkin::createFromTextures($this->textures['SKIN']);
}
public function getCape(): ?TexturesPropertyValueCape {
if (!isset($this->textures['CAPE'])) {
return null;
}
return new TexturesPropertyValueCape($this->textures['CAPE']['url']);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValueCape {
/**
* @var string
*/
private $url;
public function __construct(string $skinUrl) {
$this->url = $skinUrl;
}
public function getUrl(): string {
return $this->url;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Ely\Mojang\Response\Properties;
class TexturesPropertyValueSkin {
/**
* @var string
*/
private $url;
/**
* @var bool
*/
private $isSlim;
public function __construct(string $skinUrl, bool $isSlim = false) {
$this->url = $skinUrl;
$this->isSlim = $isSlim;
}
public static function createFromTextures(array $textures): self {
$model = &$textures['metainfo']['model']; // ampersand to avoid notice about unexpected key
return new static($textures['url'], $model === 'slim');
}
public function getUrl(): string {
return $this->url;
}
public function isSlim(): bool {
return $this->isSlim;
}
}