Merge branch 'download-tracking' into develop

This commit is contained in:
Refringe 2024-09-25 17:06:03 -04:00
commit a94a1418ef
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
9 changed files with 214 additions and 6 deletions

View File

@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers;
use App\Models\ModVersion;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class ModVersionController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, int $modId, string $slug, string $version): RedirectResponse
{
$modVersion = ModVersion::whereModId($modId)
->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);
}
}

View File

@ -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;
});
}

View File

@ -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<ModFactory> */
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.
*

View File

@ -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;
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Policies;
use App\Models\ModVersion;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class ModVersionPolicy
{
use HandlesAuthorization;
/**
* Determine whether the user can view any mod versions.
*/
public function viewAny(?User $user): bool
{
return true;
}
/**
* Determine whether the user can view the mod version.
*/
public function view(?User $user, ModVersion $modVersion): bool
{
return true;
}
/**
* Determine whether the user can create mod versions.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the mod version.
*/
public function update(User $user, ModVersion $modVersion): bool
{
return false;
}
/**
* Determine whether the user can delete the mod version.
*/
public function delete(User $user, ModVersion $modVersion): bool
{
return false;
}
/**
* Determine whether the user can restore the mod version.
*/
public function restore(User $user, ModVersion $modVersion): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the mod version.
*/
public function forceDelete(User $user, ModVersion $modVersion): bool
{
return false;
}
}

View File

@ -61,7 +61,7 @@
</div>
{{-- Mobile Download Button --}}
<a href="{{ $mod->latestVersion->link }}" class="block lg:hidden">
<a href="{{ $mod->downloadUrl() }}" class="block lg:hidden">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a>
@ -100,7 +100,7 @@
<div class="p-4 mb-4 sm:p-6 bg-white dark:bg-gray-950 rounded-xl shadow-md dark:shadow-gray-950 drop-shadow-2xl">
<div class="pb-6 border-b-2 border-gray-200 dark:border-gray-800">
<div class="flex items-center justify-between">
<a class="text-2xl font-extrabold" href="{{ $version->link }}">
<a class="text-2xl font-extrabold" href="{{ $version->downloadUrl() }}">
{{ __('Version') }} {{ $version->version }}
</a>
<p class="text-gray-700 dark:text-gray-300" title="{{ __('Exactly') }} {{ $version->downloads }}">{{ Number::downloads($version->downloads) }} {{ __('Downloads') }}</p>
@ -151,7 +151,7 @@
<div class="col-span-1 flex flex-col gap-6">
{{-- Desktop Download Button --}}
<a href="{{ $mod->latestVersion->link }}" class="hidden lg:block">
<a href="{{ $mod->downloadUrl() }}" class="hidden lg:block">
<button class="text-lg font-extrabold hover:bg-cyan-400 dark:hover:bg-cyan-600 shadow-md dark:shadow-gray-950 drop-shadow-2xl bg-cyan-500 dark:bg-cyan-700 rounded-xl w-full h-20">{{ __('Download Latest Version') }} ({{ $mod->latestVersion->version }})</button>
</a>

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\ModController;
use App\Http\Controllers\ModVersionController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route;
@ -15,6 +16,15 @@ Route::middleware(['auth.banned'])->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');
});

View File

@ -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");
});

View File

@ -1,5 +1,6 @@
<?php
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -141,3 +142,48 @@ it('updates the parent mods updated_at column when updated', function () {
expect($version->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);
});