herpaderpaldent/seat-notifications

View on GitHub
src/Http/Controllers/Discord/DiscordServerController.php

Summary

Maintainability
C
7 hrs
Test Coverage
<?php
/**
 * MIT License.
 *
 * Copyright (c) 2019. Felix Huber
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

namespace Herpaderpaldent\Seat\SeatNotifications\Http\Controllers\Discord;

use Exception;
use GuzzleHttp\Client;
use Herpaderpaldent\Seat\SeatNotifications\Caches\RedisRateLimitProvider;
use Herpaderpaldent\Seat\SeatNotifications\Http\Validations\ValidateOAuth;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use RestCord\DiscordClient;
use Seat\Web\Http\Controllers\Controller;
use WebSocket\Client as WebSocketClient;

class DiscordServerController extends Controller
{

    /**
     * Scopes used in OAuth flow with Discord.
     */
    const SCOPES = [
        'bot',
    ];

    /**
     * @var string
     */
    protected $gateway = 'wss://gateway.discord.gg';

    /**
     * @param ValidateOAuth $request
     *
     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
     */
    public function postConfiguration(ValidateOAuth $request)
    {
        $state = time();

        // store data into the session until OAuth confirmation
        session(['herpaderp.seatnotifications.discord.credentials' => [
            'state'         => $state,
            'client_id'     => $request->input('discord-configuration-client'),
            'client_secret' => $request->input('discord-configuration-secret'),
            'bot_token'     => $request->input('discord-configuration-bot'),
        ]]);

        return redirect($this->oAuthAuthorization($request->input('discord-configuration-client'), $state));
    }

    /**
     * @param Request $request
     *
     * @return \Illuminate\Http\RedirectResponse
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    public function callback(Request $request)
    {
        // get back pending OAuth credentials validation from session
        $credentials = $request->session()->get('herpaderp.seatnotifications.discord.credentials');

        $request->session()->forget('herpaderp.seatnotifications.discord.credentials');

        if (! $this->isValidCallback($credentials))
            return redirect()->route('home')
                ->with('error', 'An error occurred while processing the request. ' .
                    'For some reason, your session was not met system requirement.');

        // ensure request is legitimate
        if ($credentials['state'] != $request->input('state')) {
            return redirect()->back()
                ->with('error', 'An error occurred while getting back the token. Returned state value is wrong. ' .
                    'In order to prevent any security issue, we stopped transaction.');
        }

        // validating Discord credentials
        try {

            $token = $this->exchangeToken($credentials['client_id'], $credentials['client_secret'],
                $request->input('code'));

            setting(['herpaderp.seatnotifications.discord.credentials.client_id', $credentials['client_id']], true);
            setting(['herpaderp.seatnotifications.discord.credentials.client_secret', $credentials['client_secret']], true);
            setting(['herpaderp.seatnotifications.discord.credentials.token', [
                'access'  => $token['access_token'],
                'refresh' => $token['refresh_token'],
                'expires' => carbon($token['request_date'])->addSeconds($token['expires_in'])->toDateTimeString(),
                'scope'  => $token['scope'],
            ]], true);
            setting(['herpaderp.seatnotifications.discord.credentials.bot_token', $credentials['bot_token']], true);
            setting(['herpaderp.seatnotifications.discord.credentials.guild_id', $request->input('guild_id')], true);

            // update Discord container
            app()->singleton('seatnotifications-discord', function () {
                return new DiscordClient([
                    'tokenType'         => 'Bot',
                    'token'             => setting('herpaderp.seatnotifications.discord.credentials.bot_token', true),
                    'rateLimitProvider' => new RedisRateLimitProvider(),
                ]);
            });

        } catch (Exception $e) {
            return redirect()->route('seatnotifications.configuration')
                ->with('error', 'An error occurred while trying to confirm OAuth credentials with Discord. ' .
                    $e->getMessage());
        }

        // Discord requires all bots to connect via a websocket connection and
        // identify at least once before any API requests over HTTP are allowed.
        // https://discordapp.com/developers/docs/topics/gateway#gateway-identify
        try {
            $this->gateway = $this->getGateway();

            $client = $this->getSocket($this->gateway);

            $client->send(json_encode([
                'op' => 2,
                'd' => [
                    'token' => setting('herpaderp.seatnotifications.discord.credentials.bot_token', true),
                    'v' => 3,
                    'compress' => false,
                    'properties' => [
                        '$os' => PHP_OS,
                        '$browser' => 'laravel-notification-channels-discord',
                        '$device' => 'laravel-notification-channels-discord',
                        '$referrer' => '',
                        '$referring_domain' => '',
                    ],
                ],
            ]));

            $response = $client->receive();
            $identified = Arr::get(json_decode($response, true), 'op') === 10;

            if (! $identified) {
                $this->error("Discord responded with an error while trying to identify the bot: $response");

                return redirect()->route('seatnotifications.configuration')
                    ->with('error', 'An error occurred while trying to create websocket connection with Discord. ' .
                        $identified);
            }

        } catch (Exception $e) {
            return redirect()->route('seatnotifications.configuration')
                ->with('error', 'An error occurred while trying to create websocket connection with Discord. ' .
                    $e->getMessage());
        }

        return redirect()->route('seatnotifications.configuration')
            ->with('success', 'The bot credentials has been set.');
    }

    /**
     * Return an authorization uri with presets scopes.
     *
     * @param $client_id
     * @param $state
     * @return string
     */
    private function oAuthAuthorization($client_id, $state)
    {
        $base_uri = 'https://discordapp.com/api/oauth2/authorize?';

        return $base_uri . http_build_query([
                'response_type' => 'code',
                'client_id'     => $client_id,
                // https://discordapi.com/permissions.html#149504
                'permissions'   => 149504,
                'scope'         => implode(' ', self::SCOPES),
                'state'         => $state,
                'redirect_uri'  => route('seatnotifications.callback.discord.server'),
            ]);
    }

    /**
     * Exchange an Authorization Code with an Access Token.
     *
     * @param string $client_id
     * @param string $client_secret
     * @param string $code
     *
     * @return array
     * @throws \GuzzleHttp\Exception\GuzzleException
     */
    private function exchangeToken(string $client_id, string $client_secret, string $code)
    {
        $payload = [
            'client_id'     => $client_id,
            'client_secret' => $client_secret,
            'grant_type'    => 'authorization_code',
            'code'          => $code,
            'redirect_uri'  => route('seatnotifications.callback.discord.server'),
            'scope'         => implode(self::SCOPES, ' '),
        ];

        $request = (new Client())->request('POST', 'https://discordapp.com/api/oauth2/token', [
            'form_params' => $payload,
        ]);

        $response = json_decode($request->getBody(), true);

        if (is_null($response))
            throw new Exception('response from Discord was empty.');

        return array_merge($response, [
            'request_date' => array_first($request->getHeader('Date')),
        ]);
    }

    /**
     * Ensure an array is containing all expected values in a valid callback session.
     *
     * @param $session_content
     * @return bool
     */
    private function isValidCallback($session_content)
    {
        $expected_array_keys = ['state', 'client_id', 'client_secret', 'bot_token'];
        $i = count($expected_array_keys);

        if (is_null($session_content))
            return false;

        if (! is_array($session_content))
            return false;

        while ($i > 0) {
            $i--;

            if (! array_key_exists($expected_array_keys[$i], $session_content))
                return false;
        }

        return true;
    }

    /**
     * Get the URL of the gateway that the socket should connect to.
     *
     * @return string
     */
    public function getGateway()
    {
        $gateway = $this->gateway;

        try {
            $response = (new Client)->get('https://discordapp.com/api/gateway', [
                'headers' => [
                    'Authorization' => 'Bot ' . setting('herpaderp.seatnotifications.discord.credentials.bot_token', true),
                ],
            ]);

            $gateway = Arr::get(json_decode($response->getBody(), true), 'url', $gateway);
        } catch (Exception $e) {

            return redirect()->route('seatnotifications.configuration')
                ->with('error', 'Could not get a websocket gateway address, defaulting to' . $gateway . '. An error occurred while trying to create websocket connection with Discord. ' .
                    $e->getMessage());
        }

        return $gateway;
    }

    /**
     * Get a websocket client for the given gateway.
     *
     * @param string $gateway
     *
     * @return \WebSocket\Client
     */
    public function getSocket($gateway)
    {
        return new WebSocketClient($gateway);
    }
}