Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/Connect/Actions/AwardAchievementAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use App\Models\PlayerGame;
use App\Models\StaticData;
use App\Models\User;
use App\Platform\Actions\ResumePlayerSessionAction;
use App\Platform\Jobs\UnlockPlayerAchievementJob;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
Expand Down Expand Up @@ -167,6 +168,19 @@ protected function process(): array
return $this->unsupportedSystem('Cannot unlock achievements for unsupported console.');
}

$playerSession = app()->make(ResumePlayerSessionAction::class)->execute(
$this->user,
$this->achievement->game,
($this->gameHash && !$this->gameHash->isMultiDiscGameHash()) ? $this->gameHash : null,
timestamp: $this->when,
userAgent: $this->userAgent,
ipAddress: $this->ipAddress,
);
Comment thread
wescopeland marked this conversation as resolved.

if ($this->connectWarning) {
$this->connectWarning->player_session_id = $playerSession->id;
}

$playerGame = PlayerGame::query()
->where('user_id', $this->user->id)
->where('game_id', $this->achievement->game_id)
Expand Down
17 changes: 17 additions & 0 deletions app/Connect/Support/GeneratesConnectWarnings.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
use App\Models\ConnectWarning;
use App\Models\Game;
use App\Platform\Services\UserAgentService;
use App\Support\Alerts\SuspiciousConnectWarningAlert;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

trait GeneratesConnectWarnings
{
Expand All @@ -23,6 +26,7 @@ public function handleRequest(Request $request): JsonResponse
if ($this->connectWarning !== null) {
$this->finalizeWarning();
$this->connectWarning->save();
Comment thread
wescopeland marked this conversation as resolved.
$this->sendNotifications();
}

return $result;
Expand Down Expand Up @@ -87,4 +91,17 @@ private function finalizeWarning(): void
}
}
}

private function sendNotifications(): void
{
if (str_contains($this->connectWarning->smells, 'repeated_validation')
|| str_contains($this->connectWarning->smells, 'wrong_client')) {

// only send one notification per user per day
$key = 'user:' . strtolower($this->connectWarning->username) . ':connect_warning_notification';
if (Cache::add($key, '1', Carbon::now()->addDay())) {
(new SuspiciousConnectWarningAlert($this->connectWarning))->send();
}
}
}
}
84 changes: 84 additions & 0 deletions app/Support/Alerts/SuspiciousConnectWarningAlert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Support\Alerts;

use App\Models\Achievement;
use App\Models\ConnectWarning;
use App\Models\Game;
use App\Models\Leaderboard;
use App\Models\User;

class SuspiciousConnectWarningAlert extends Alert
{
public function __construct(
public readonly ConnectWarning $warning,
) {
}

public function toDiscordMessage(): string
{
$user = User::whereName($this->warning->username)->firstOrFail();

$gameId = match ($this->warning->method) {
'submitlbentry' => Leaderboard::find($this->warning->related_id)?->game_id,
default => Achievement::find($this->warning->related_id)?->game_id,
};
Comment thread
wescopeland marked this conversation as resolved.

if ($gameId) {
// repeated_validation takes precedence
if (str_contains($this->warning->smells, 'repeated_validation')) {
return $this->repeatedValidationDiscordMessage($user, $gameId);
}

if (str_contains($this->warning->smells, 'wrong_client')) {
return $this->wrongClientDiscordMessage($user, $gameId);
}
}

return "";
}

/**
* "[Scott](<https://retroachievements.org/user/Scott>) unlocked [Sonic the Hedgehog](<https://retroachievements.org/game/1>) achievements using PPSSPP - [Activity](<https://retroachievements.org/user/Scott/game/1/activity>)"
*/
private function wrongClientDiscordMessage(User $user, int $gameId): string
{
$playerUrl = route('user.show', ['user' => $user]);

$game = Game::findOrFail($gameId);
$gameUrl = route('game.show', ['game' => $game]);

$activityUrl = route('user.game.activity.show', ['user' => $user, 'game' => $game]);

return sprintf(
match ($this->warning->method) {
'submitlbentry' => '[%s](<%s>) submitted [%s](<%s>) leaderboard entries using %s - [Activity](<%s>)',
default => '[%s](<%s>) unlocked [%s](<%s>) achievements using %s - [Activity](<%s>)',
},
$user->display_name,
$playerUrl,
$game->title,
$gameUrl,
$this->warning->user_agent,
$activityUrl,
);
}

/**
* "[Scott](<https://retroachievements.org/user/Scott>) used the same incorrect validation hash for multiple unlocks - [Activity](<https://retroachievements.org/user/Scott/game/1/activity>)"
*/
private function repeatedValidationDiscordMessage(User $user, int $gameId): string
{
$playerUrl = route('user.show', ['user' => $user]);
$activityUrl = route('user.game.activity.show', ['user' => $user, 'game' => $gameId]);

return sprintf(
'[%s](<%s>) used the same incorrect validation hash for multiple unlocks - [Activity](<%s>)',
$user->display_name,
$playerUrl,
$activityUrl,
);
}
}
101 changes: 101 additions & 0 deletions tests/Feature/Connect/AwardAchievementTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
use App\Models\System;
use App\Models\User;
use App\Platform\Enums\UnlockMode;
use App\Support\Alerts\Jobs\SendAlertWebhookJob;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Tests\Feature\Concerns\TestsEmulatorUserAgent;
use Tests\Feature\Platform\Concerns\TestsPlayerAchievements;
Expand Down Expand Up @@ -149,6 +151,12 @@ public static function getWarning(Achievement $achievement): ConnectWarning
->whereNull('extra')
->first();
}

public static function initializeFakeQueue(): void
{
Queue::fake([SendAlertWebhookJob::class]);
config(['services.discord.alerts_webhook.suspicious_connect_warning' => 'https://discord.com/api/webhooks/test']);
}
}

beforeEach(function () {
Expand Down Expand Up @@ -2487,6 +2495,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$unlock1Date = $now->clone()->subMinutes(65);
$this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash);

AwardAchievementTestHelpers::initializeFakeQueue();

// $userAgentValid is associated to the "Test Client". Attach a differing system to that.
$otherSystem = System::factory()->create();
Emulator::where('name', 'Test Client')->first()->systems()->attach($otherSystem->id);
Expand Down Expand Up @@ -2530,6 +2540,11 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$this->assertEquals($validationHash, $warning->validation_hash);
$this->assertEquals('wrong_client', $warning->smells);
$this->assertEquals($this->userAgentValid, $warning->user_agent);

// notification should be sent
Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) {
return $job->webhookUrl === 'https://discord.com/api/webhooks/test';
});
});

test('user agent for incorrect system on multi-system client does not generate warning', function () {
Expand Down Expand Up @@ -2647,6 +2662,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$unlock1Date = $now->clone()->subMinutes(65);
$this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash);

AwardAchievementTestHelpers::initializeFakeQueue();

// do the hardcore unlock
$validationHash = md5('This is the wrong validation hash');
$scoreBefore = $this->user->points_hardcore;
Expand Down Expand Up @@ -2686,6 +2703,9 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$this->assertEquals($validationHash, $warning->validation_hash);
$this->assertEquals('bad_validation', $warning->smells);
$this->assertEquals($this->userAgentValid, $warning->user_agent);

// notification should not be sent
Queue::assertNotPushed(SendAlertWebhookJob::class);
});

test('validation hash containing 0 offset still unlocks hardcore achievements', function () {
Expand Down Expand Up @@ -2795,6 +2815,8 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$unlock1Date = $now->clone()->subMinutes(65);
$this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash);

AwardAchievementTestHelpers::initializeFakeQueue();

// do the hardcore unlock
$validationHash = md5('This is the wrong validation hash');
$scoreBefore = $this->user->points_hardcore;
Expand Down Expand Up @@ -2845,5 +2867,84 @@ public static function getWarning(Achievement $achievement): ConnectWarning
$this->assertEquals($validationHash, $warning->validation_hash);
$this->assertEquals('bad_validation,repeated_validation', $warning->smells);
$this->assertEquals($this->userAgentValid, $warning->user_agent);

// notification should be sent
Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) {
return $job->webhookUrl === 'https://discord.com/api/webhooks/test';
});
});

test('only one warning notification per user per day', function () {
$data = AwardAchievementTestHelpers::createGame();
$game = $data['game'];
$achievement1 = $data['achievements'][0];
$achievement3 = $data['achievements'][2];
$achievement4 = $data['achievements'][3];
$gameHash = $data['gameHash'];
$now = Carbon::now();

$unlock1Date = $now->clone()->subMinutes(65);
$this->addHardcoreUnlock($this->user, $achievement1, $unlock1Date, $gameHash);

AwardAchievementTestHelpers::initializeFakeQueue();

// do the hardcore unlock
$validationHash = md5('This is the wrong validation hash');
$scoreBefore = $this->user->points_hardcore;
$softcoreScoreBefore = $this->user->points;
$truePointsBefore = $this->user->points_weighted;

ConnectWarning::create([
'method' => 'awardachievement',
'username' => $this->user->username,
'related_type' => 'achievement',
'related_id' => $achievement1->id,
'hardcore' => 1,
'validation_hash' => $validationHash,
'smells' => 'bad_validation',
'user_agent' => $this->userAgentValid,
]);

$this->withHeaders(['User-Agent' => $this->userAgentValid])
->get($this->apiUrl('awardachievement', [
'a' => $achievement3->id,
'h' => 1,
'm' => $gameHash->md5,
'v' => $validationHash,
]))
->assertStatus(200)
->assertExactJson([
'Success' => true,
'AchievementID' => $achievement3->id,
'AchievementsRemaining' => 4,
'Score' => $scoreBefore + $achievement3->points,
'SoftcoreScore' => $softcoreScoreBefore,
]);

// notification should be sent
Queue::assertPushedOn('alerts', SendAlertWebhookJob::class, function ($job) {
return $job->webhookUrl === 'https://discord.com/api/webhooks/test';
});

AwardAchievementTestHelpers::initializeFakeQueue();

$this->withHeaders(['User-Agent' => $this->userAgentValid])
->get($this->apiUrl('awardachievement', [
'a' => $achievement4->id,
'h' => 1,
'm' => $gameHash->md5,
'v' => $validationHash,
]))
->assertStatus(200)
->assertExactJson([
'Success' => true,
'AchievementID' => $achievement4->id,
'AchievementsRemaining' => 3,
'Score' => $scoreBefore + $achievement3->points + $achievement4->points,
'SoftcoreScore' => $softcoreScoreBefore,
]);

// notification should not be sent
Queue::assertNotPushed(SendAlertWebhookJob::class);
});
});
Loading
Loading