Skip to content

Commit

Permalink
Merge pull request #19 from DripDropz/discord_link
Browse files Browse the repository at this point in the history
Allow users to link their discord account & Leaderboard qualifiers api endpoint
  • Loading branch information
latheesan-k authored Dec 8, 2024
2 parents 1fc6233 + cd315bb commit 2f6311f
Show file tree
Hide file tree
Showing 9 changed files with 1,387 additions and 108 deletions.
12 changes: 8 additions & 4 deletions application/app/Http/Controllers/API/AuthController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function providers(): JsonResponse
* @urlParam authProvider string required The selected auth provider. Example: twitter
* @queryParam reference string required Unique user/session identifier in your application. Example: abcd1234
*
* @response status=322 scenario="When successfully initialised"
* @response status=322 scenario="When successfully initialised" [Redirect]
* @response status=429 scenario="Too Many Requests" [No Content]
* @responseFile status=400 scenario="Bad Request" resources/api-responses/400.json
* @responseFile status=401 scenario="Unauthorized" resources/api-responses/401.json
Expand Down Expand Up @@ -363,7 +363,7 @@ public function verifyWallet(string $publicApiKey, Request $request): JsonRespon
* @urlParam publicApiKey string required The project's public api key. Example: 414f7c5c-b932-4d26-9570-1c2f954b64ed
* @queryParam reference string required Unique user/session identifier in your application that was used in the initialization step. Example: abcd1234
*
* @response status=200 scenario="OK - Authenticated" {"authenticated":true,"account":{"auth_provider":"google","auth_provider_id":"117571893339073554831","auth_wallet":"eternl","auth_name":"Latheesan","auth_email":"latheesan@example.com","auth_avatar":"https://example.com/profile.jpg", "linked_wallet_stake_address": null},"session":{"reference":"your-app-identifier-123","session_id":"265dfd21-0fa2-4895-9277-87d2ed74a294","auth_country_code":"GB","authenticated_at":"2024-11-21 22:46:16"}}
* @response status=200 scenario="OK - Authenticated" {"authenticated":true,"account":{"auth_provider":"google","auth_provider_id":"117571893339073554831","auth_wallet":"eternl","auth_name":"Latheesan","auth_email":"latheesan@example.com","auth_avatar":"https://example.com/profile.jpg", "linked_wallet_stake_address": null, "linked_discord_account": null},"session":{"reference":"your-app-identifier-123","session_id":"265dfd21-0fa2-4895-9277-87d2ed74a294","auth_country_code":"GB","authenticated_at":"2024-11-21 22:46:16"},"qualifier":null}
* @response status=200 scenario="OK - Unauthenticated" {"authenticated":false,"account":null,"session":null}
* @response status=429 scenario="Too Many Requests" [No Content]
* @responseFile status=400 scenario="Bad Request" resources/api-responses/400.json
Expand Down Expand Up @@ -418,6 +418,7 @@ public function check(string $publicApiKey, Request $request): JsonResponse
'auth_email' => $projectAccountSession->account->auth_email,
'auth_avatar' => $projectAccountSession->account->auth_avatar,
'linked_wallet_stake_address' => $projectAccountSession->account->linked_wallet_stake_address,
'linked_discord_account' => $projectAccountSession->account->linked_discord_account,
] : null,
'session' => $isAuthenticated ? [
'reference' => $projectAccountSession->reference,
Expand Down Expand Up @@ -456,7 +457,7 @@ public function check(string $publicApiKey, Request $request): JsonResponse
* @bodyParam session_id string required Previously authentication session id. Example: 069ff9f1-87ad-43b0-90a9-05493a330273
* @bodyParam new_reference string required New unique user/session identifier in your application. Example: 069ff9f1-87ad-43b0-90a9-05493a330273
*
* @response status=200 scenario="Successfully Refreshed" {"authenticated":true,"account":{"auth_provider":"google","auth_provider_id":"117571893339073554831","auth_wallet":"eternl","auth_name":"Latheesan","auth_email":"latheesan@example.com","auth_avatar":"https://example.com/profile.jpg"},"session":{"reference":"your-app-identifier-123","session_id":"265dfd21-0fa2-4895-9277-87d2ed74a294","auth_country_code":"GB","authenticated_at":"2024-11-21 22:46:16"}}
* @response status=200 scenario="Successfully Refreshed" {"authenticated":true,"account":{"auth_provider":"google","auth_provider_id":"117571893339073554831","auth_wallet":"eternl","auth_name":"Latheesan","auth_email":"latheesan@example.com","auth_avatar":"https://example.com/profile.jpg", "linked_wallet_stake_address": null, "linked_discord_account": null},"session":{"reference":"your-app-identifier-123","session_id":"265dfd21-0fa2-4895-9277-87d2ed74a294","auth_country_code":"GB","authenticated_at":"2024-11-21 22:46:16"},"qualifier":null}
* @response status=429 scenario="Too Many Requests" [No Content]
* @responseFile status=400 scenario="Bad Request" resources/api-responses/400.json
* @responseFile status=401 scenario="Unauthorized" resources/api-responses/401.json
Expand Down Expand Up @@ -510,7 +511,7 @@ public function refresh(string $publicApiKey, Request $request): JsonResponse
// Load specific project account session
$projectAccountSession = ProjectAccountSession::query()
->where('session_id', $request->input('session_id'))
->with('account')
->with(['account', 'stats'])
->first();
if (!$projectAccountSession || (int) $projectAccountSession->authenticated_at->diffInSeconds(now()) > $project->session_valid_for_seconds) {
return response()->json([
Expand Down Expand Up @@ -551,13 +552,16 @@ public function refresh(string $publicApiKey, Request $request): JsonResponse
'auth_name' => $projectAccountSession->account->auth_name,
'auth_email' => $projectAccountSession->account->auth_email,
'auth_avatar' => $projectAccountSession->account->auth_avatar,
'linked_wallet_stake_address' => $projectAccountSession->account->linked_wallet_stake_address,
'linked_discord_account' => $projectAccountSession->account->linked_discord_account,
],
'session' => [
'reference' => $newProjectAccountSession->reference,
'session_id' => $newProjectAccountSession->session_id,
'auth_country_code' => $newProjectAccountSession->auth_country_code,
'authenticated_at' => $newProjectAccountSession->authenticated_at->toDateTimeString(),
],
'qualifier' => isset($projectAccountSession->stats['qualifier']) ? $projectAccountSession->stats['qualifier'] : null,
]);

} catch (Throwable $exception) {
Expand Down
148 changes: 148 additions & 0 deletions application/app/Http/Controllers/API/StatsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\API;

use App\Enums\AuthProviderType;
use App\Http\Controllers\Controller;
use App\Jobs\HydraDoomAccountStatsJob;
use App\Models\Project;
Expand All @@ -10,8 +11,12 @@
use App\Models\ProjectAccountStats;
use App\Traits\GEOBlockTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\DB;
use Laravel\Socialite\Facades\Socialite;
use Throwable;

/**
Expand Down Expand Up @@ -228,6 +233,76 @@ public function sessionLinkWalletAddress(string $publicApiKey, Request $request)
return response()->noContent();
}

/**
* Session Link Discord Account
*
* @urlParam publicApiKey string required The project's public api key. Example: 414f7c5c-b932-4d26-9570-1c2f954b64ed
* @urlParam sessionId string required Previously authentication session id. Example: 069ff9f1-87ad-43b0-90a9-05493a330273
*
* @response status=322 scenario="When successfully initialised" [Redirect]
* @response status=429 scenario="Too Many Requests" [No Content]
* @responseFile status=400 scenario="Bad Request" resources/api-responses/400.json
* @responseFile status=401 scenario="Unauthorized" resources/api-responses/401.json
* @responseFile status=500 scenario="Internal Server Error" resources/api-responses/500.json
*/
public function sessionLinkDiscordAccount(string $publicApiKey, string $sessionId, Request $request): JsonResponse|RedirectResponse
{
// Load project by public api key
$project = Cache::remember(sprintf('project:%s', $publicApiKey), 600, function () use ($publicApiKey) {
$project = Project::query()
->where('public_api_key', $publicApiKey)
->first();
if (!$project) {
return false;
}
return $project;
});
if (!$project) {
return response()->json([
'error' => __('Unauthorized'),
'reason' => __('Invalid project public api key'),
], 401);
}

// Check if this request should be geo-blocked
if ($this->isGEOBlocked($project, $request)) {
return response()->json([
'error' => __('Unauthorized'),
'reason' => __('Access not permitted'),
], 401);
}

// Load specific project account session
$projectAccountSession = ProjectAccountSession::query()
->where('session_id', $sessionId)
->with('account')
->first();
if (!$projectAccountSession || (int) $projectAccountSession->authenticated_at->diffInSeconds(now()) > $project->session_valid_for_seconds) {
return response()->json([
'error' => __('Unauthorized'),
'reason' => __('Invalid session id or session expired'),
], 401);
}

// Check if linked discord account already present
if (!empty($projectAccountSession->account->linked_discord_account)) {
return response()->json([
'error' => __('Bad Request'),
'reason' => __('Already linked'),
], 400);
}

// Redirect to discord with cookie
return Socialite::driver(AuthProviderType::DISCORD->value)
->redirect()
->withCookies([
Cookie::make(
'link_discord_account',
$projectAccountSession->account->id,
),
]);
}

/**
* Leaderboard
*
Expand Down Expand Up @@ -277,4 +352,77 @@ public function leaderboard(string $publicApiKey, Request $request): JsonRespons
return response()
->json($leaderboard);
}

/**
* Leaderboard Qualifiers
*
* @response 200 scenario="OK" {"key1":"value1", "key2":"value3"}
* @response status=429 scenario="Too Many Requests" [No Content]
* @response status=503 scenario="Service Unavailable" {"error":"Service Unavailable", "reason":"Reason for this error"}
* @responseFile status=401 scenario="Unauthorized" resources/api-responses/401.json
* @responseFile status=500 scenario="Internal Server Error" resources/api-responses/500.json
*/
public function leaderboardQualifiers(string $publicApiKey, Request $request): JsonResponse
{
// Load project by public api key
$project = Cache::remember(sprintf('project:%s', $publicApiKey), 600, function () use ($publicApiKey) {
$project = Project::query()
->where('public_api_key', $publicApiKey)
->first();
if (!$project) {
return false;
}
return $project;
});
if (!$project) {
return response()->json([
'error' => __('Unauthorized'),
'reason' => __('Invalid project public api key'),
], 401);
}

// Check if this request should be geo-blocked
if ($this->isGEOBlocked($project, $request)) {
return response()->json([
'error' => __('Unauthorized'),
'reason' => __('Access not permitted'),
], 401);
}

// Load from cache (or warm up cache)
$leaderboardQualifiers = Cache::remember(sprintf('project-leaderboard-qualifiers:%d', $project->id), 60, function () use ($publicApiKey, $project) {

// Load qualified players query
$sql = <<<QUERY
select project_accounts.auth_provider,
project_accounts.auth_name,
project_accounts.auth_avatar,
project_accounts.linked_wallet_stake_address,
project_accounts.linked_discord_account,
project_account_stats.qualifier
from project_account_stats
join project_accounts on project_account_stats.project_account_id = project_accounts.id
where project_account_stats.project_id = ?
and JSON_EXTRACT(project_account_stats.qualifier, '$.is_qualified') = true
QUERY;

// Query results
$results = DB::select($sql, [$project->id]);

// Format and return results
return collect($results)->map(function ($row) {
$row = (array) $row;
$row['auth_name'] = decrypt($row['auth_name']);
$row['auth_avatar'] = decrypt($row['auth_avatar']);
$row['linked_discord_account'] = json_decode($row['linked_discord_account'], true);
$row['qualifier'] = json_decode($row['qualifier'], true);
return $row;
})->toArray();

});

// Return Cached data
return response()
->json($leaderboardQualifiers);
}
}
50 changes: 50 additions & 0 deletions application/app/Http/Controllers/SocialAuthCallbackController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use App\Traits\LogExceptionTrait;
use App\Traits\WalletAuthTrait;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User;
use Laravel\Socialite\Facades\Socialite;
Expand All @@ -27,6 +28,11 @@ public function handle(string $authProvider, Request $request)
// Validate requested auth provider
$this->validateRequestedAuthProvider($authProvider);

// Retrieve link discord account from cookie
if ($authProvider === AuthProviderType::DISCORD->value && $projectAccountId = (int) $request->cookie('link_discord_account')) {
return $this->handleLinkDiscordAccount($projectAccountId, $authProvider);
}

// Retrieve auth attempt from cookie
[$authReference, $publicAPIKey] = $this->retrieveAuthAttemptFromCookie($request, $authProvider);

Expand Down Expand Up @@ -69,6 +75,50 @@ public function handle(string $authProvider, Request $request)
}
}

public function handleLinkDiscordAccount(int $projectAccountId, string $authProvider)
{
// Load project account
$projectAccount = ProjectAccount::query()
->where('id', $projectAccountId)
->first();
if (!$projectAccount) {
exit(__('Invalid attempt to link discord account, please try again'));
}

// Load social user from callback
$socialUser = $this->getSocialUser($authProvider);

// Update linked discord account
$projectAccount->update([
'linked_discord_account' => [
'id' => $socialUser->id,
'name' => $socialUser->getName(),
],
]);

// Handle empty avatar
$avatar = $socialUser->getAvatar();
if (empty($avatar)) {
$avatar = sprintf('https://api.dicebear.com/9.x/pixel-art/svg?seed=%s', $socialUser->getName());
}

// Forget cookie
Cookie::queue(Cookie::forget('link_discord_account'));

// Success
return view('success', [
'linked' => [
'name' => $projectAccount->auth_name,
'email' => $projectAccount->auth_email,
'avatar' => $projectAccount->auth_avatar,
],
'authProvider' => ucfirst($authProvider),
'name' => $socialUser->getName(),
'email' => $socialUser->getEmail(),
'avatar' => $avatar,
]);
}

public function validateRequestedAuthProvider(string $authProvider): void
{
if (!in_array($authProvider, AuthProviderType::values(), true)) {
Expand Down
9 changes: 9 additions & 0 deletions application/app/Models/ProjectAccount.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ProjectAccount extends Model
'generated_wallet_mnemonic',
'generated_wallet_stake_address',
'linked_wallet_stake_address',
'linked_discord_account',
];

protected $casts = [
Expand Down Expand Up @@ -78,4 +79,12 @@ protected function generatedWalletMnemonic(): Attribute
set: fn (string|null $value) => $value ? encrypt($value) : null,
);
}

protected function linkedDiscordAccount(): Attribute
{
return Attribute::make(
get: fn (string|null $value) => $value ? json_decode($value, true) : null,
set: fn (array|null $value) => $value ? json_encode($value) : null,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('project_accounts', function (Blueprint $table) {
$table->json('linked_discord_account')->after('linked_wallet_stake_address')->nullable();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('project_accounts', function (Blueprint $table) {
$table->dropColumn('linked_discord_account');
});
}
};
Loading

0 comments on commit 2f6311f

Please sign in to comment.