diff --git a/app/Http/Controllers/ModVersionController.php b/app/Http/Controllers/ModVersionController.php new file mode 100644 index 0000000..090c0d7 --- /dev/null +++ b/app/Http/Controllers/ModVersionController.php @@ -0,0 +1,42 @@ +whereVersion($version) + ->firstOrFail(); + + if ($modVersion->mod->slug !== $slug) { + abort(404); + } + + $this->authorize('view', $modVersion); + + // Rate limit the downloads. + $rateKey = 'mod-download:'.($request->user()?->id ?: $request->ip()); + if (RateLimiter::tooManyAttempts($rateKey, maxAttempts: 5)) { // Max attempts is per minute. + abort(429); + } + + // Increment downloads counts in the background. + defer(fn () => $modVersion->incrementDownloads()); + + // Increment the rate limiter. + RateLimiter::increment($rateKey); + + // Redirect to the download link, using a 307 status code to prevent browsers from caching. + return redirect($modVersion->link, 307); + } +} diff --git a/app/Livewire/User/FollowCard.php b/app/Livewire/User/FollowCard.php index c62b53f..325b585 100644 --- a/app/Livewire/User/FollowCard.php +++ b/app/Livewire/User/FollowCard.php @@ -138,6 +138,8 @@ class FollowCard extends Component // Add the follow status based on the preloaded IDs. $user->follows = $followingIds->contains($user->id); + // TODO: The above follows property doesn't exist on the User model. What was I smoking? + return $user; }); } diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 292a49c..83fb891 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Http\Filters\V1\QueryFilter; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; -use Database\Factories\ModFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -22,9 +21,7 @@ use Laravel\Scout\Searchable; class Mod extends Model { - /** @use HasFactory */ use HasFactory; - use Searchable; use SoftDeletes; @@ -46,6 +43,18 @@ class Mod extends Model $this->saveQuietly(); } + /** + * Build the URL to download the latest version of this mod. + */ + public function downloadUrl(bool $absolute = false): string + { + return route('mod.version.download', [ + $this->id, + $this->slug, + $this->latestVersion->version, + ], absolute: $absolute); + } + /** * The relationship between a mod and its users. * diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index 15d867b..406cfaa 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -130,4 +130,26 @@ class ModVersion extends Model ->orderByDesc('version_patch') ->orderByDesc('version_pre_release'); } + + /** + * Build the download URL for this mod version. + */ + public function downloadUrl(bool $absolute = false): string + { + return route('mod.version.download', [$this->mod->id, $this->mod->slug, $this->version], absolute: $absolute); + } + + /** + * Increment the download count for this mod version. + */ + public function incrementDownloads(): int + { + $this->downloads++; + $this->save(); + + // Recalculate the total download count for this mod. + $this->mod->calculateDownloads(); + + return $this->downloads; + } } diff --git a/app/Policies/ModVersionPolicy.php b/app/Policies/ModVersionPolicy.php new file mode 100644 index 0000000..2b54cb7 --- /dev/null +++ b/app/Policies/ModVersionPolicy.php @@ -0,0 +1,68 @@ + {{-- Mobile Download Button --}} - + @@ -100,7 +100,7 @@
- + {{ __('Version') }} {{ $version->version }}

{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}

@@ -151,7 +151,7 @@
{{-- Desktop Download Button --}} - diff --git a/routes/web.php b/routes/web.php index 4641d92..d718b8b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::get('/mod/{mod}/{slug}', 'show')->where(['mod' => '[0-9]+'])->name('mod.show'); }); + Route::controller(ModVersionController::class)->group(function () { + Route::get('/mod/download/{mod}/{slug}/{version}', 'show') + ->where([ + 'mod' => '[0-9]+', + 'slug' => '[a-z0-9-]+', + ]) + ->name('mod.version.download'); + }); + Route::controller(UserController::class)->group(function () { Route::get('/user/{user}/{username}', 'show')->where(['user' => '[0-9]+'])->name('user.show'); }); diff --git a/tests/Feature/Mod/ModTest.php b/tests/Feature/Mod/ModTest.php index f853a64..1514cf8 100644 --- a/tests/Feature/Mod/ModTest.php +++ b/tests/Feature/Mod/ModTest.php @@ -50,3 +50,12 @@ it('displays the latest version on the mod detail page', function () { // Assert the latest version is in the latest download button $response->assertSeeText(__('Download Latest Version')." ($latestVersion)"); }); + +it('builds download links using the latest mod version', function () { + $mod = Mod::factory()->create(['id' => 1, 'slug' => 'test-mod']); + ModVersion::factory()->recycle($mod)->create(['version' => '1.2.3']); + ModVersion::factory()->recycle($mod)->create(['version' => '1.3.0']); + $modVersion = ModVersion::factory()->recycle($mod)->create(['version' => '1.3.4']); + + expect($mod->downloadUrl())->toEqual("/mod/download/$mod->id/$mod->slug/$modVersion->version"); +}); diff --git a/tests/Feature/Mod/ModVersionTest.php b/tests/Feature/Mod/ModVersionTest.php index 22c6be6..ebbdd58 100644 --- a/tests/Feature/Mod/ModVersionTest.php +++ b/tests/Feature/Mod/ModVersionTest.php @@ -1,5 +1,6 @@ mod->updated_at)->not->toEqual($originalDate) ->and($version->mod->updated_at->format('Y-m-d'))->toEqual(now()->format('Y-m-d')); }); + +it('builds download links using the specified version', function () { + $mod = Mod::factory()->create(['id' => 1, 'slug' => 'test-mod']); + $modVersion1 = ModVersion::factory()->recycle($mod)->create(['version' => '1.2.3']); + $modVersion2 = ModVersion::factory()->recycle($mod)->create(['version' => '1.3.0']); + $modVersion3 = ModVersion::factory()->recycle($mod)->create(['version' => '1.3.4']); + + expect($modVersion1->downloadUrl())->toEqual("/mod/download/$mod->id/$mod->slug/$modVersion1->version") + ->and($modVersion2->downloadUrl())->toEqual("/mod/download/$mod->id/$mod->slug/$modVersion2->version") + ->and($modVersion3->downloadUrl())->toEqual("/mod/download/$mod->id/$mod->slug/$modVersion3->version"); +}); + +it('increments download counts when downloaded', function () { + $mod = Mod::factory()->create(['downloads' => 0]); + $modVersion = ModVersion::factory()->recycle($mod)->create(['downloads' => 0]); + + $request = $this->get($modVersion->downloadUrl()); + $request->assertStatus(307); + + $modVersion->refresh(); + + expect($modVersion->downloads)->toEqual(1) + ->and($modVersion->mod->downloads)->toEqual(1); +}); + +it('rate limits download links from being hit', function () { + $mod = Mod::factory()->create(['downloads' => 0]); + $modVersion = ModVersion::factory()->recycle($mod)->create(['downloads' => 0]); + + // The first 5 requests should be fine. + for ($i = 0; $i < 5; $i++) { + $request = $this->get($modVersion->downloadUrl()); + $request->assertStatus(307); + } + + // The 6th request should be rate limited. + $request = $this->get($modVersion->downloadUrl()); + $request->assertStatus(429); + + $modVersion->refresh(); + + // The download count should still be 5. + expect($modVersion->downloads)->toEqual(5) + ->and($modVersion->mod->downloads)->toEqual(5); +});