diff --git a/backend/config/routes.php b/backend/config/routes.php index e25df13..616c5bb 100644 --- a/backend/config/routes.php +++ b/backend/config/routes.php @@ -179,9 +179,6 @@ $group->get('/json', App\Controller\Api\JsonAction::class) ->setName('api:json'); - $group->post('/vrc_api', App\Controller\Api\VrcApiAction::class) - ->setName('api:vrc_api'); - $group->group('/comments', function (RouteCollectorProxy $group) { $group->get('/{location}', App\Controller\Api\CommentsController::class . ':listAction') ->setName('api:comments'); diff --git a/backend/config/services.php b/backend/config/services.php index d251eb1..3badf34 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -13,6 +13,7 @@ use Psr\Cache\CacheItemPoolInterface; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel as PsrLogLevel; use Psr\SimpleCache\CacheInterface; use Slim\Factory\ServerRequestCreatorFactory; use Slim\Handlers\Strategies\RequestResponse; @@ -132,11 +133,26 @@ }, // HTTP client - HttpClient::class => static fn() => new HttpClient([ - 'headers' => [ - 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', - ], - ]), + HttpClient::class => static function ( + Logger $logger + ) { + $stack = GuzzleHttp\HandlerStack::create(); + + $stack->push( + GuzzleHttp\Middleware::log( + $logger, + new GuzzleHttp\MessageFormatter('HTTP client {method} call to {uri} produced response {code}'), + PsrLogLevel::DEBUG + ) + ); + + return new HttpClient([ + 'handler' => $stack, + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36', + ], + ]); + }, // Filesystem Utilities Filesystem::class => static fn() => new Filesystem(), diff --git a/backend/src/Controller/Api/VrcApiAction.php b/backend/src/Controller/Api/VrcApiAction.php deleted file mode 100644 index 7cbd3bb..0000000 --- a/backend/src/Controller/Api/VrcApiAction.php +++ /dev/null @@ -1,40 +0,0 @@ -getBody()->getContents(); - $request = json_decode($postdata, true); - - if ($request['type'] == 'notification') { - $notification = $request['content']; - $notification_type = $notification['type']; - - if ($notification_type == 'friendRequest') { - $notification_id = $notification['id']; - - $this->vrcApi->sendRequest( - method: 'PUT', - path: "api/1/auth/user/notifications/$notification_id/accept", - priority: true, - async: true - ); - } - } - - return $response->withStatus(200); - } -} diff --git a/backend/src/Service/VrcApi.php b/backend/src/Service/VrcApi.php index a2299bd..5cebb36 100644 --- a/backend/src/Service/VrcApi.php +++ b/backend/src/Service/VrcApi.php @@ -2,67 +2,49 @@ namespace App\Service; -use App\Environment; +use App\Service\VrcApi\AuthMiddleware; +use App\Service\VrcApi\RateLimitStore; use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\MessageFormatter; +use GuzzleHttp\Middleware; +use Psr\Log\LoggerInterface; +use Psr\Log\LogLevel; +use Spatie\GuzzleRateLimiterMiddleware\RateLimiterMiddleware; final readonly class VrcApi { + public const string VRCAPI_BASE_URL = 'https://api.vrchat.cloud/api/1/'; + + private Client $httpClient; + public function __construct( - private Client $http + LoggerInterface $logger, + AuthMiddleware $authMiddleware, + RateLimitStore $rateLimitStore ) { - } - - /** - * Send a request to the API proxy server - * - * @param string $method The HTTP method to perform (POST, GET, PUT, etc) - * @param string $path The path to use (/api/1/visits etc) - * @param string|null $body The HTTP body (if any, set to null if not wanted) - * @param bool $priority If the request should use the high-priority queue - * @param bool $async If the request should be async (non-failable, ideal for friend requests/invite requests) - * @return string|array - */ - public function sendRequest( - string $method, - string $path, - ?string $body = null, - bool $priority = false, - bool $async = false - ): string|array { - /** @noinspection HttpUrlsUsage */ - $uri = 'http://149.106.100.136:8124/' . $path; + $stack = HandlerStack::create(); + $stack->push( + Middleware::log( + $logger, + new MessageFormatter('VRCAPI client {method} call to {uri} produced response {code}'), + LogLevel::DEBUG + ) + ); + $stack->push(RateLimiterMiddleware::perSecond(1, $rateLimitStore)); + $stack->push($authMiddleware); - $requestConfig = [ + $this->httpClient = new Client([ + 'handler' => $stack, + 'base_uri' => self::VRCAPI_BASE_URL, 'headers' => [ - 'Authorization' => $_ENV['VRCHAT_API_KEY'], + 'User-Agent' => 'WaterWolf/1.0 Isaac@waterwolf.club', ], - ]; - - if ($priority) { - $requestConfig['headers']['X-Priority'] = 'high'; - } - if ($async) { - $requestConfig['headers']['X-Background'] = '1'; - } - - if ($body !== null) { - $requestConfig['json'] = $body; - } - - $response = $this->http->request( - $method, - $uri, - $requestConfig - ); + ]); + } - if ($response->getHeaderLine('Content-Type') === 'application/json') { - return json_decode( - $response->getBody()->getContents(), - true, - JSON_THROW_ON_ERROR - ); - } else { - return $response->getBody()->getContents(); - } + public function getHttpClient(): Client + { + return $this->httpClient; } } diff --git a/backend/src/Service/VrcApi/AuthMiddleware.php b/backend/src/Service/VrcApi/AuthMiddleware.php new file mode 100644 index 0000000..45fc238 --- /dev/null +++ b/backend/src/Service/VrcApi/AuthMiddleware.php @@ -0,0 +1,108 @@ +username = $_ENV['VRCHAT_USERNAME'] ?? null; + $this->password = $_ENV['VRCHAT_PASSWORD'] ?? null; + $this->totp = $_ENV['VRCHAT_TOTP'] ?? null; + + $this->cookieJar = new FileCookieJar(Environment::getTempDirectory() . '/vrcapi_cookies'); + } + + public function __invoke(callable $next): \Closure + { + return function (RequestInterface $request, array $options = []) use (&$next) { + $request = $this->applyAuth($request); + return $next($request, $options); + }; + } + + protected function applyAuth(RequestInterface $request): RequestInterface + { + if (!$this->hasValidCookies()) { + $this->reAuth(); + } + + return $this->cookieJar->withCookieHeader($request); + } + + private function reAuth(): void + { + $options = [ + 'base_uri' => VrcApi::VRCAPI_BASE_URL, + 'cookies' => $this->cookieJar, + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode( + urlencode($this->username) . ':' . urlencode($this->password) + ), + ], + ]; + + if (!empty($this->totp)) { + $totp = TOTP::createFromSecret(str_replace(' ', '', strtoupper($this->totp))); + $this->httpClient->post( + 'auth/twofactorauth/totp/verify', + [ + ...$options, + 'json' => [ + 'code' => $totp->now(), + ], + ] + ); + } else { + $this->httpClient->get( + 'auth/user', + $options + ); + } + } + + private function hasValidCookies(): bool + { + if ($this->cookieJar->count() === 0) { + return false; + } + + $cookieNames = []; + + /** @var SetCookie $cookie */ + foreach ($this->cookieJar->getIterator() as $cookie) { + if (!$cookie->isExpired()) { + $cookieNames[$cookie->getName()] = $cookie; + } + } + + if (!isset($cookieNames[self::VRC_AUTH_COOKIE_NAME])) { + return false; + } + + if (!empty($this->totp) && !isset($cookieNames[self::VRC_TOTP_COOKIE_NAME])) { + return false; + } + + return true; + } +} diff --git a/backend/src/Service/VrcApi/RateLimitStore.php b/backend/src/Service/VrcApi/RateLimitStore.php new file mode 100644 index 0000000..a1a2439 --- /dev/null +++ b/backend/src/Service/VrcApi/RateLimitStore.php @@ -0,0 +1,29 @@ +psrCache->get(self::CACHE_KEY, []); + } + + public function push(int $timestamp, int $limit): void + { + $entries = $this->get(); + $entries[] = $timestamp; + + $this->psrCache->set(self::CACHE_KEY, $entries, $limit); + } +} diff --git a/composer.json b/composer.json index fdc68f8..033c535 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,8 @@ "robmorgan/phinx": "^0.16.0", "slim/http": "^1.3", "slim/slim": "^4.12", + "spatie/guzzle-rate-limiter-middleware": "^2.0", + "spomky-labs/otphp": "^11.2", "symfony/amazon-mailer": "^7.0", "symfony/cache": "^7.0", "symfony/console": "^7.0", diff --git a/composer.lock b/composer.lock index ba2f7b3..c7e704d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c4c346c2c8c88068dda7d0dc2de746bc", + "content-hash": "3e9d55ef4491f2255d3c6c97190a457e", "packages": [ { "name": "async-aws/core", @@ -3561,6 +3561,138 @@ ], "time": "2024-03-03T21:25:30+00:00" }, + { + "name": "spatie/guzzle-rate-limiter-middleware", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/guzzle-rate-limiter-middleware.git", + "reference": "8679f7a22e46edc182046f18b83bacbc627b0600" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/guzzle-rate-limiter-middleware/zipball/8679f7a22e46edc182046f18b83bacbc627b0600", + "reference": "8679f7a22e46edc182046f18b83bacbc627b0600", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "larapack/dd": "^1.0", + "phpunit/phpunit": "^9.3.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\GuzzleRateLimiterMiddleware\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian De Deyne", + "email": "sebastiandedeyne@gmail.com", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A rate limiter for Guzzle", + "homepage": "https://github.com/spatie/guzzle-rate-limiter-middleware", + "keywords": [ + "guzzle-rate-limiter-middleware", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/guzzle-rate-limiter-middleware/issues", + "source": "https://github.com/spatie/guzzle-rate-limiter-middleware/tree/2.0.1" + }, + "time": "2020-12-19T18:47:06+00:00" + }, + { + "name": "spomky-labs/otphp", + "version": "11.2.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/otphp.git", + "reference": "9a1569038bb1c8e98040b14b8bcbba54f25e7795" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/otphp/zipball/9a1569038bb1c8e98040b14b8bcbba54f25e7795", + "reference": "9a1569038bb1c8e98040b14b8bcbba54f25e7795", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "paragonie/constant_time_encoding": "^2.0", + "php": "^8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0", + "infection/infection": "^0.26", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.0", + "phpunit/phpunit": "^9.5.26", + "qossmic/deptrac-shim": "^1.0", + "rector/rector": "^0.15", + "symfony/phpunit-bridge": "^6.1", + "symplify/easy-coding-standard": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "OTPHP\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/otphp/contributors" + } + ], + "description": "A PHP library for generating one time passwords according to RFC 4226 (HOTP Algorithm) and the RFC 6238 (TOTP Algorithm) and compatible with Google Authenticator", + "homepage": "https://github.com/Spomky-Labs/otphp", + "keywords": [ + "FreeOTP", + "RFC 4226", + "RFC 6238", + "google authenticator", + "hotp", + "otp", + "totp" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/otphp/issues", + "source": "https://github.com/Spomky-Labs/otphp/tree/11.2.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2023-03-16T19:16:25+00:00" + }, { "name": "symfony/amazon-mailer", "version": "v7.0.3", diff --git a/dev.dist.env b/dev.dist.env index ada1de8..cce12e9 100644 --- a/dev.dist.env +++ b/dev.dist.env @@ -17,8 +17,10 @@ MARIADB_ROOT_PASSWORD="CD2132428BD13725AA3EFCCF92DB" # AWS Simple E-mail Service (SES) DSN MAILER_DSN="ses+smtp://ACCESS_KEY:SECRET_KEY@default?region=us-west-2" -# API Key for VRChat API proxy -VRCHAT_API_KEY="" +# Credentials for VRChat API +VRCHAT_USERNAME="" +VRCHAT_PASSWORD="" +VRCHAT_TOTP="" # Webhook URL to dispatch DISCORD_WEBHOOK_URL=""