Merge branch 'user-profiles' into develop

This commit is contained in:
Refringe 2024-08-08 14:01:29 -04:00
commit 65e416e4d9
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
29 changed files with 633 additions and 100 deletions

View File

@ -21,12 +21,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'],
])->validateWithBag('updateProfileInformation'); ])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) { if (isset($input['photo'])) {
$user->updateProfilePhoto($input['photo']); $user->updateProfilePhoto($input['photo']);
} }
if (isset($input['cover'])) {
$user->updateCoverPhoto($input['cover']);
}
if ($input['email'] !== $user->email && if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) { $user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input); $this->updateVerifiedUser($user, $input);

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
use Illuminate\Support\Str;
class ApiController extends Controller
{
public static function shouldInclude(string $relationship): bool
{
$param = request()->get('include');
if (! $param) {
return false;
}
$includeValues = explode(',', Str::lower($param));
return in_array(Str::lower($relationship), $includeValues);
}
}

View File

@ -2,13 +2,12 @@
namespace App\Http\Controllers\Api\V0; namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\StoreModRequest;
use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Requests\Api\V0\UpdateModRequest;
use App\Http\Resources\Api\V0\ModResource; use App\Http\Resources\Api\V0\ModResource;
use App\Models\Mod; use App\Models\Mod;
class ModController extends Controller class ModController extends ApiController
{ {
/** /**
* Display a listing of the resource. * Display a listing of the resource.

View File

@ -2,13 +2,12 @@
namespace App\Http\Controllers\Api\V0; namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\StoreUserRequest;
use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest;
use App\Http\Resources\Api\V0\UserResource; use App\Http\Resources\Api\V0\UserResource;
use App\Models\User; use App\Models\User;
class UsersController extends Controller class UsersController extends ApiController
{ {
/** /**
* Display a listing of the resource. * Display a listing of the resource.

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
class UserController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, User $user, string $username)
{
if ($user->slug() !== $username) {
abort(404);
}
if ($request->user()?->cannot('view', $user)) {
abort(403);
}
return view('user.show', compact('user'));
}
}

View File

@ -2,6 +2,7 @@
namespace App\Http\Resources\Api\V0; namespace App\Http\Resources\Api\V0;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\Mod; use App\Models\Mod;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -35,23 +36,29 @@ class ModResource extends JsonResource
'published_at' => $this->published_at, 'published_at' => $this->published_at,
], ],
'relationships' => [ 'relationships' => [
'users' => [ 'users' => $this->users->map(fn ($user) => [
'data' => $this->users->map(fn ($user) => [ 'data' => [
'type' => 'user', 'type' => 'user',
'id' => $user->id, 'id' => $user->id,
])->toArray(), ],
'links' => [
// TODO: Provide 'links.self' to user profile 'self' => $user->profileUrl(),
//'links' => ['self' => '#'], ],
], ])->toArray(),
'license' => [ 'license' => [
'data' => [ [
'type' => 'license', 'data' => [
'id' => $this->license_id, 'type' => 'license',
'id' => $this->license_id,
],
], ],
], ],
], ],
'included' => $this->users->map(fn ($user) => new UserResource($user)),
'includes' => $this->when(
ApiController::shouldInclude('users'),
fn () => $this->users->map(fn ($user) => new UserResource($user))
),
// TODO: Provide 'included' data for attached 'license': // TODO: Provide 'included' data for attached 'license':
//new LicenseResource($this->license) //new LicenseResource($this->license)

View File

@ -30,11 +30,13 @@ class UserResource extends JsonResource
], ],
], ],
], ],
// TODO: Provide 'included' data for attached 'user_role' // TODO: Provide 'included' data for attached 'user_role'
//'included' => [new UserRoleResource($this->role)], //'included' => [new UserRoleResource($this->role)],
// TODO: Provide 'links.self' to user profile: 'links' => [
//'links' => ['self' => '#'], 'self' => $this->profileUrl(),
],
]; ];
} }
} }

View File

@ -36,6 +36,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
{ {
// Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary // Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary
// tables to store the data to save on memory; we don't want this to be a hog. // tables to store the data to save on memory; we don't want this to be a hog.
$this->bringUserAvatarLocal();
$this->bringFileAuthorsLocal(); $this->bringFileAuthorsLocal();
$this->bringFileOptionsLocal(); $this->bringFileOptionsLocal();
$this->bringFileContentLocal(); $this->bringFileContentLocal();
@ -58,6 +59,34 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
Artisan::call('cache:clear'); Artisan::call('cache:clear');
} }
/**
* Bring the user avatar table from the Hub database to the local database temporary table.
*/
protected function bringUserAvatarLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar');
DB::statement('CREATE TEMPORARY TABLE temp_user_avatar (
avatarID INT,
avatarExtension VARCHAR(255),
userID INT,
fileHash VARCHAR(255)
)');
DB::connection('mysql_hub')
->table('wcf1_user_avatar')
->orderBy('avatarID')
->chunk(200, function ($avatars) {
foreach ($avatars as $avatar) {
DB::table('temp_user_avatar')->insert([
'avatarID' => (int) $avatar->avatarID,
'avatarExtension' => $avatar->avatarExtension,
'userID' => (int) $avatar->userID,
'fileHash' => $avatar->fileHash,
]);
}
});
}
/** /**
* Bring the file authors from the Hub database to the local database temporary table. * Bring the file authors from the Hub database to the local database temporary table.
*/ */
@ -172,15 +201,33 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
*/ */
protected function importUsers(): void protected function importUsers(): void
{ {
// Initialize a cURL handler for downloading mod thumbnails.
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
DB::connection('mysql_hub') DB::connection('mysql_hub')
->table('wcf1_user as u') ->table('wcf1_user as u')
->select('u.userID', 'u.username', 'u.email', 'u.password', 'u.registrationDate', 'u.banned', 'u.banReason', 'u.banExpires', 'u.rankID', 'r.rankTitle') ->select(
'u.userID',
'u.username',
'u.email',
'u.password',
'u.registrationDate',
'u.banned',
'u.banReason',
'u.banExpires',
'u.coverPhotoHash',
'u.coverPhotoExtension',
'u.rankID',
'r.rankTitle',
)
->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID') ->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID')
->chunkById(250, function (Collection $users) { ->chunkById(250, function (Collection $users) use ($curl) {
$userData = $bannedUsers = $userRanks = []; $userData = $bannedUsers = $userRanks = [];
foreach ($users as $user) { foreach ($users as $user) {
$userData[] = $this->collectUserData($user); $userData[] = $this->collectUserData($curl, $user);
$bannedUserData = $this->collectBannedUserData($user); $bannedUserData = $this->collectBannedUserData($user);
if ($bannedUserData) { if ($bannedUserData) {
@ -197,15 +244,20 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$this->handleBannedUsers($bannedUsers); $this->handleBannedUsers($bannedUsers);
$this->handleUserRoles($userRanks); $this->handleUserRoles($userRanks);
}, 'userID'); }, 'userID');
// Close the cURL handler.
curl_close($curl);
} }
protected function collectUserData($user): array protected function collectUserData(CurlHandle $curl, object $user): array
{ {
return [ return [
'hub_id' => (int) $user->userID, 'hub_id' => (int) $user->userID,
'name' => $user->username, 'name' => $user->username,
'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'), 'email' => Str::lower($user->email),
'password' => $this->cleanPasswordHash($user->password), 'password' => $this->cleanPasswordHash($user->password),
'profile_photo_path' => $this->fetchUserAvatar($curl, $user),
'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $user),
'created_at' => $this->cleanRegistrationDate($user->registrationDate), 'created_at' => $this->cleanRegistrationDate($user->registrationDate),
'updated_at' => now('UTC')->toDateTimeString(), 'updated_at' => now('UTC')->toDateTimeString(),
]; ];
@ -224,6 +276,75 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
return str_starts_with($clean, '$2') ? $clean : ''; return str_starts_with($clean, '$2') ? $clean : '';
} }
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserAvatar(CurlHandle $curl, object $user): string
{
// Fetch the user's avatar data from the temporary table.
$avatar = DB::table('temp_user_avatar')->where('userID', $user->userID)->first();
if (! $avatar) {
return '';
}
$hashShort = substr($avatar->fileHash, 0, 2);
$fileName = $avatar->fileHash.'.'.$avatar->avatarExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/avatars/'.$hashShort.'/'.$avatar->avatarID.'-'.$fileName;
$relativePath = 'user-avatars/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
/**
* Fetch and store an image from the Hub.
*/
protected function fetchAndStoreImage(CurlHandle $curl, string $hubUrl, string $relativePath): string
{
// Determine the disk to use based on the environment.
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
// Check to make sure the image doesn't already exist.
if (Storage::disk($disk)->exists($relativePath)) {
return $relativePath; // Already exists, return the path.
}
// Download the image using the cURL handler.
curl_setopt($curl, CURLOPT_URL, $hubUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));
return '';
}
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
return $relativePath;
}
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserCoverPhoto(CurlHandle $curl, object $user): string
{
if (empty($user->coverPhotoHash) || empty($user->coverPhotoExtension)) {
return '';
}
$hashShort = substr($user->coverPhotoHash, 0, 2);
$fileName = $user->coverPhotoHash.'.'.$user->coverPhotoExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$user->userID.'-'.$fileName;
$relativePath = 'user-covers/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
/** /**
* Clean the registration date from the Hub database. * Clean the registration date from the Hub database.
*/ */
@ -295,9 +416,6 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
} }
} }
/*
* Build an array of user rank data ready to be inserted into the local database.
*/
protected function collectUserRankData($user): ?array protected function collectUserRankData($user): ?array
{ {
if ($user->rankID && $user->rankTitle) { if ($user->rankID && $user->rankTitle) {
@ -590,31 +708,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
$hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName; $hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName;
$relativePath = 'mods/'.$fileName; $relativePath = 'mods/'.$fileName;
// Determine the disk to use based on the environment. return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
// Check to make sure the image doesn't already exist.
if (Storage::disk($disk)->exists($relativePath)) {
return $relativePath; // Already exists, return the path.
}
// Download the image using the cURL handler.
curl_setopt($curl, CURLOPT_URL, $hubUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download a mod thumbnail. cURL error: '.curl_error($curl));
return '';
}
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
return $relativePath;
} }
/** /**
@ -693,6 +787,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue
public function failed(Exception $exception): void public function failed(Exception $exception): void
{ {
// Explicitly drop the temporary tables. // Explicitly drop the temporary tables.
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_author'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_author');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content');

View File

@ -0,0 +1,76 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
use Livewire\Features\SupportRedirects\Redirector;
class UpdateProfileForm extends UpdateProfileInformationForm
{
/**
* The new cover photo for the user.
*
* @var mixed
*/
public $cover;
/**
* When the photo is temporarily uploaded.
*/
public function updatedPhoto(): void
{
$this->validate([
'photo' => 'image|mimes:jpg,jpeg,png|max:1024', // 1MB Max
]);
}
/**
* When the cover is temporarily uploaded.
*/
public function updatedCover(): void
{
$this->validate([
'cover' => 'image|mimes:jpg,jpeg,png|max:2048', // 2MB Max
]);
}
/**
* Update the user's profile information.
*/
public function updateProfileInformation(UpdatesUserProfileInformation $updater): RedirectResponse|Redirector|null
{
$this->resetErrorBag();
$updater->update(
Auth::user(),
$this->photo || $this->cover
? array_merge($this->state, array_filter([
'photo' => $this->photo,
'cover' => $this->cover,
])) : $this->state
);
if (isset($this->photo) || isset($this->cover)) {
return redirect()->route('profile.show');
}
$this->dispatch('saved');
$this->dispatch('refresh-navigation-menu');
return null;
}
/**
* Delete user's profile photo.
*/
public function deleteCoverPhoto(): void
{
Auth::user()->deleteCoverPhoto();
$this->dispatch('refresh-navigation-menu');
}
}

View File

@ -11,6 +11,9 @@ class License extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* The relationship between a license and mod.
*/
public function mods(): HasMany public function mods(): HasMany
{ {
return $this->hasMany(Mod::class); return $this->hasMany(Mod::class);

View File

@ -25,6 +25,9 @@ class Mod extends Model
{ {
use HasFactory, Searchable, SoftDeletes; use HasFactory, Searchable, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void protected static function booted(): void
{ {
// Apply the global scope to exclude disabled mods. // Apply the global scope to exclude disabled mods.
@ -34,18 +37,24 @@ class Mod extends Model
} }
/** /**
* The users that belong to the mod. * The relationship between a mod and its users.
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(User::class); return $this->belongsToMany(User::class);
} }
/**
* The relationship between a mod and its license.
*/
public function license(): BelongsTo public function license(): BelongsTo
{ {
return $this->belongsTo(License::class); return $this->belongsTo(License::class);
} }
/**
* The relationship between a mod and its versions.
*/
public function versions(): HasMany public function versions(): HasMany
{ {
return $this->hasMany(ModVersion::class)->orderByDesc('version'); return $this->hasMany(ModVersion::class)->orderByDesc('version');
@ -62,6 +71,9 @@ class Mod extends Model
]); ]);
} }
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(): HasOne public function lastUpdatedVersion(): HasOne
{ {
return $this->hasOne(ModVersion::class) return $this->hasOne(ModVersion::class)
@ -69,7 +81,7 @@ class Mod extends Model
} }
/** /**
* Get the indexable data array for the model. * The data that is searchable by Scout.
*/ */
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
@ -97,11 +109,12 @@ class Mod extends Model
{ {
return $this->hasOne(ModVersion::class) return $this->hasOne(ModVersion::class)
->orderByDesc('version') ->orderByDesc('version')
->orderByDesc('updated_at')
->take(1); ->take(1);
} }
/** /**
* Determine if the model should be searchable. * Determine if the model instance should be searchable.
*/ */
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
@ -109,7 +122,7 @@ class Mod extends Model
} }
/** /**
* Get the URL to the thumbnail. * Build the URL to the mod's thumbnail.
*/ */
public function thumbnailUrl(): Attribute public function thumbnailUrl(): Attribute
{ {
@ -121,7 +134,7 @@ class Mod extends Model
} }
/** /**
* Get the disk where the thumbnail is stored. * Get the disk where the thumbnail is stored based on the current environment.
*/ */
protected function thumbnailDisk(): string protected function thumbnailDisk(): string
{ {
@ -131,6 +144,9 @@ class Mod extends Model
}; };
} }
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
@ -142,7 +158,7 @@ class Mod extends Model
} }
/** /**
* Ensure the slug is always lower case when retrieved and slugified when saved. * Mutate the slug attribute to always be lower case on get and slugified on set.
*/ */
protected function slug(): Attribute protected function slug(): Attribute
{ {

View File

@ -19,12 +19,18 @@ class ModVersion extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void protected static function booted(): void
{ {
static::addGlobalScope(new DisabledScope); static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope); static::addGlobalScope(new PublishedScope);
} }
/**
* The relationship between a mod version and mod.
*/
public function mod(): BelongsTo public function mod(): BelongsTo
{ {
return $this->belongsTo(Mod::class); return $this->belongsTo(Mod::class);
@ -38,6 +44,9 @@ class ModVersion extends Model
return $this->hasMany(ModDependency::class); return $this->hasMany(ModDependency::class);
} }
/**
* The relationship between a mod version and SPT version.
*/
public function sptVersion(): BelongsTo public function sptVersion(): BelongsTo
{ {
return $this->belongsTo(SptVersion::class); return $this->belongsTo(SptVersion::class);

View File

@ -11,6 +11,9 @@ class SptVersion extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory, SoftDeletes;
/**
* The relationship between an SPT version and mod version.
*/
public function modVersions(): HasMany public function modVersions(): HasMany
{ {
return $this->hasMany(ModVersion::class); return $this->hasMany(ModVersion::class);

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Notifications\ResetPassword; use App\Notifications\ResetPassword;
use App\Notifications\VerifyEmail; use App\Notifications\VerifyEmail;
use App\Traits\HasCoverPhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -21,6 +22,7 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
use Bannable; use Bannable;
use HasApiTokens; use HasApiTokens;
use HasCoverPhoto;
use HasFactory; use HasFactory;
use HasProfilePhoto; use HasProfilePhoto;
use Notifiable; use Notifiable;
@ -38,11 +40,17 @@ class User extends Authenticatable implements MustVerifyEmail
'profile_photo_url', 'profile_photo_url',
]; ];
/**
* The relationship between a user and their mods.
*/
public function mods(): BelongsToMany public function mods(): BelongsToMany
{ {
return $this->belongsToMany(Mod::class); return $this->belongsToMany(Mod::class);
} }
/**
* The data that is searchable by Scout.
*/
public function toSearchableArray(): array public function toSearchableArray(): array
{ {
return [ return [
@ -51,28 +59,25 @@ class User extends Authenticatable implements MustVerifyEmail
]; ];
} }
/**
* Determine if the model instance should be searchable.
*/
public function shouldBeSearchable(): bool public function shouldBeSearchable(): bool
{ {
return ! is_null($this->email_verified_at); return ! is_null($this->email_verified_at);
} }
public function assignRole(UserRole $role): bool /**
{ * Check if the user has the role of a moderator.
$this->role()->associate($role); */
return $this->save();
}
public function role(): BelongsTo
{
return $this->belongsTo(UserRole::class, 'user_role_id');
}
public function isMod(): bool public function isMod(): bool
{ {
return Str::lower($this->role?->name) === 'moderator'; return Str::lower($this->role?->name) === 'moderator';
} }
/**
* Check if the user has the role of an administrator.
*/
public function isAdmin(): bool public function isAdmin(): bool
{ {
return Str::lower($this->role?->name) === 'administrator'; return Str::lower($this->role?->name) === 'administrator';
@ -94,12 +99,41 @@ class User extends Authenticatable implements MustVerifyEmail
$this->notify(new ResetPassword($token)); $this->notify(new ResetPassword($token));
} }
protected function casts(): array /**
* Get the relative URL to the user's profile page.
*/
public function profileUrl(): string
{ {
return [ return route('user.show', [
'email_verified_at' => 'datetime', 'user' => $this->id,
'password' => 'hashed', 'username' => $this->slug(),
]; ]);
}
/**
* Get the slug of the user's name.
*/
public function slug(): string
{
return Str::lower(Str::slug($this->name));
}
/**
* Assign a role to the user.
*/
public function assignRole(UserRole $role): bool
{
$this->role()->associate($role);
return $this->save();
}
/**
* The relationship between a user and their role.
*/
public function role(): BelongsTo
{
return $this->belongsTo(UserRole::class, 'user_role_id');
} }
/** /**
@ -107,9 +141,17 @@ class User extends Authenticatable implements MustVerifyEmail
*/ */
protected function profilePhotoDisk(): string protected function profilePhotoDisk(): string
{ {
return match (config('app.env')) { return config('filesystems.asset_upload', 'public');
'production' => 'r2', // Cloudflare R2 Storage }
default => 'public', // Local
}; /**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
} }
} }

View File

@ -10,6 +10,9 @@ class UserRole extends Model
{ {
use HasFactory; use HasFactory;
/**
* The relationship between a user role and users.
*/
public function users(): HasMany public function users(): HasMany
{ {
return $this->hasMany(User::class); return $this->hasMany(User::class);

View File

@ -8,7 +8,7 @@ use App\Models\User;
class ModPolicy class ModPolicy
{ {
/** /**
* Determine whether the user can view any models. * Determine whether the user can view multiple models.
*/ */
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
@ -16,7 +16,7 @@ class ModPolicy
} }
/** /**
* Determine whether the user can view the model. * Determine whether the user can view a specific model.
*/ */
public function view(?User $user, Mod $mod): bool public function view(?User $user, Mod $mod): bool
{ {

View File

@ -0,0 +1,47 @@
<?php
namespace App\Policies;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;
class UserPolicy
{
use HandlesAuthorization;
public function viewAny(User $user): bool
{
return false;
}
public function view(User $userCurrent, User $userResource): bool
{
// TODO: check to see if the userResource has blocked the userCurrent.
return true;
}
public function create(User $user): bool
{
return false;
}
public function update(User $user, User $model): bool
{
return false;
}
public function delete(User $user, User $model): bool
{
return false;
}
public function restore(User $user, User $model): bool
{
return false;
}
public function forceDelete(User $user, User $model): bool
{
return false;
}
}

View File

@ -0,0 +1,72 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
trait HasCoverPhoto
{
/**
* Update the user's cover photo.
*/
public function updateCoverPhoto(UploadedFile $cover, $storagePath = 'cover-photos'): void
{
tap($this->cover_photo_path, function ($previous) use ($cover, $storagePath) {
$this->forceFill([
'cover_photo_path' => $cover->storePublicly(
$storagePath, ['disk' => $this->coverPhotoDisk()]
),
])->save();
if ($previous) {
Storage::disk($this->coverPhotoDisk())->delete($previous);
}
});
}
/**
* Get the disk that cover photos should be stored on.
*/
protected function coverPhotoDisk(): string
{
return config('filesystems.asset_upload', 'public');
}
/**
* Delete the user's cover photo.
*/
public function deleteCoverPhoto(): void
{
if (is_null($this->cover_photo_path)) {
return;
}
Storage::disk($this->coverPhotoDisk())->delete($this->cover_photo_path);
$this->forceFill([
'cover_photo_path' => null,
])->save();
}
/**
* Get the URL to the user's cover photo.
*/
public function coverPhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->cover_photo_path
? Storage::disk($this->coverPhotoDisk())->url($this->cover_photo_path)
: $this->defaultCoverPhotoUrl();
});
}
/**
* Get the default profile photo URL if no profile photo has been uploaded.
*/
protected function defaultCoverPhotoUrl(): string
{
return 'https://picsum.photos/seed/'.urlencode($this->name).'/720/100?blur=2';
}
}

View File

@ -15,6 +15,18 @@ return [
'default' => env('FILESYSTEM_DISK', 'local'), 'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Default Asset Upload Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that assets should be
| uploaded to. Typically, this will be either the "public" or "r2" disk.
|
*/
'asset_upload' => env('ASSET_UPLOAD_DISK', 'public'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Filesystem Disks | Filesystem Disks

View File

@ -207,7 +207,7 @@ return [
'maxJobs' => 0, 'maxJobs' => 0,
'memory' => 256, 'memory' => 256,
'tries' => 1, 'tries' => 1,
'timeout' => 900, // 15 Minutes 'timeout' => 1500, // 25 Minutes
'nice' => 0, 'nice' => 0,
], ],
], ],

View File

@ -64,17 +64,17 @@ return [
*/ */
'temporary_file_upload' => [ 'temporary_file_upload' => [
'disk' => null, // Example: 'local', 's3' | Default: 'default' 'disk' => null,
'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) 'rules' => ['file', 'max:12288'],
'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' 'directory' => null,
'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' 'middleware' => 'throttle:5,1',
'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... 'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4',
'mov', 'avi', 'wmv', 'mp3', 'm4a', 'mov', 'avi', 'wmv', 'mp3', 'm4a',
'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'jpg', 'jpeg', 'mpga', 'webp', 'wma',
], ],
'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... 'max_upload_time' => 5,
'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... 'cleanup' => true,
], ],
/* /*

View File

@ -29,7 +29,8 @@ return new class extends Migration
->nullOnDelete() ->nullOnDelete()
->cascadeOnUpdate(); ->cascadeOnUpdate();
$table->rememberToken(); $table->rememberToken();
$table->string('profile_photo_path', 2048)->nullable(); $table->string('profile_photo_path', 2048)->nullable()->default(null);
$table->string('cover_photo_path', 2048)->nullable()->default(null);
$table->timestamps(); $table->timestamps();
}); });

View File

@ -21,7 +21,9 @@
{{ $mod->{$versionScope}->sptVersion->version }} {{ $mod->{$versionScope}->sptVersion->version }}
</span> </span>
</div> </div>
<p class="text-sm italic text-slate-600 dark:text-gray-200">By {{ $mod->users->pluck('name')->implode(', ') }}</p> <p class="text-sm italic text-slate-600 dark:text-gray-200">
By {{ $mod->users->pluck('name')->implode(', ') }}
</p>
<p class="mt-2 text-slate-500 dark:text-gray-300">{{ $mod->teaser }}</p> <p class="mt-2 text-slate-500 dark:text-gray-300">{{ $mod->teaser }}</p>
</div> </div>
<x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/> <x-mod-list-stats :mod="$mod" :modVersion="$mod->{$versionScope}"/>

View File

@ -32,7 +32,12 @@
{{ $latestVersion->sptVersion->version }} {{ $latestVersion->sptVersion->version }}
</span> </span>
</div> </div>
<p>{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}</p> <p>
{{ __('Created by') }}
@foreach ($mod->users as $user)
<a href="{{ $user->profileUrl() }}" class="text-slate-600 dark:text-gray-200 hover:underline">{{ $user->name }}</a>{{ $loop->last ? '' : ',' }}
@endforeach
</p>
<p>{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}</p> <p>{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}</p>
<p>{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}</p> <p>{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}</p>
</div> </div>

View File

@ -63,18 +63,24 @@
</svg> </svg>
{{ __('Dashboard') }} {{ __('Dashboard') }}
</a> </a>
</div> <a href="{{ auth()->user()->profileUrl() }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem">
<div class="flex flex-col py-1.5">
<a href="{{ route('profile.show') }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/> <path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/>
</svg> </svg>
{{ __('Profile') }} {{ __('Profile') }}
</a> </a>
</div>
<div class="flex flex-col py-1.5">
<a href="{{ route('profile.show') }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.923-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" clip-rule="evenodd"/>
</svg>
{{ __('Edit Profile') }}
</a>
@if (Laravel\Jetstream\Jetstream::hasApiFeatures()) @if (Laravel\Jetstream\Jetstream::hasApiFeatures())
<a href="{{ route('api-tokens.index') }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem"> <a href="{{ route('api-tokens.index') }}" class="flex items-center gap-2 bg-slate-100 px-4 py-2 text-sm text-slate-700 hover:bg-slate-800/5 hover:text-black focus-visible:bg-slate-800/10 focus-visible:text-black focus-visible:outline-none dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-100/5 dark:hover:text-white dark:focus-visible:bg-slate-100/10 dark:focus-visible:text-white" role="menuitem">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="w-4 h-4"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 00-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 00-2.282.819l-.922 1.597a1.875 1.875 0 00.432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 000 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 00-.432 2.385l.922 1.597a1.875 1.875 0 002.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 002.28-.819l.923-1.597a1.875 1.875 0 00-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 000-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 00-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 00-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 00-1.85-1.567h-1.843zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" clip-rule="evenodd"/> <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 0 1-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 0 1 4.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0 1 12 15a9.065 9.065 0 0 0-6.23-.693L5 14.5m14.8.8 1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0 1 12 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
</svg> </svg>
{{ __('API Tokens') }} {{ __('API Tokens') }}
</a> </a>

View File

@ -8,7 +8,7 @@
<div> <div>
<div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto py-10 sm:px-6 lg:px-8">
@if (Laravel\Fortify\Features::canUpdateProfileInformation()) @if (Laravel\Fortify\Features::canUpdateProfileInformation())
@livewire('profile.update-profile-information-form') @livewire('profile.update-profile-form')
<x-section-border /> <x-section-border />
@endif @endif

View File

@ -8,7 +8,7 @@
</x-slot> </x-slot>
<x-slot name="form"> <x-slot name="form">
<!-- Profile Photo --> <!-- Profile Picture -->
@if (Laravel\Jetstream\Jetstream::managesProfilePhotos()) @if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
<div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4"> <div x-data="{photoName: null, photoPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Profile Photo File Input --> <!-- Profile Photo File Input -->
@ -24,7 +24,7 @@
reader.readAsDataURL($refs.photo.files[0]); reader.readAsDataURL($refs.photo.files[0]);
" /> " />
<x-label for="photo" value="{{ __('Photo') }}" /> <x-label for="photo" value="{{ __('Profile Picture') }}" />
<!-- Current Profile Photo --> <!-- Current Profile Photo -->
<div class="mt-2" x-show="! photoPreview"> <div class="mt-2" x-show="! photoPreview">
@ -52,6 +52,48 @@
</div> </div>
@endif @endif
<!-- Cover Picture -->
<div x-data="{coverName: null, coverPreview: null}" class="col-span-6 sm:col-span-4">
<!-- Cover Picture File Input -->
<input type="file" id="cover" class="hidden"
wire:model.live="cover"
x-ref="cover"
x-on:change="
coverName = $refs.cover.files[0].name;
const reader = new FileReader();
reader.onload = (e) => {
coverPreview = e.target.result;
};
reader.readAsDataURL($refs.cover.files[0]);
" />
<x-label for="cover" value="{{ __('Cover Picture') }}" />
<!-- Current Cover Photo -->
<div class="mt-2" x-show="! coverPreview">
<img src="{{ $this->user->cover_photo_url }}" alt="{{ $this->user->name }}" class="rounded-sm h-20 w-60 object-cover">
</div>
<!-- New Cover Photo Preview -->
<div class="mt-2" x-show="coverPreview" style="display: none;">
<span class="block h-20 w-60 bg-cover bg-no-repeat bg-center"
x-bind:style="'background-image: url(\'' + coverPreview + '\');'">
</span>
</div>
<x-secondary-button class="mt-2 me-2" type="button" x-on:click.prevent="$refs.cover.click()">
{{ __('Select A New Cover Photo') }}
</x-secondary-button>
@if ($this->user->cover_photo_path)
<x-secondary-button type="button" class="mt-2" wire:click="deleteCoverPhoto">
{{ __('Remove Cover Photo') }}
</x-secondary-button>
@endif
<x-input-error for="cover" class="mt-2" />
</div>
<!-- Name --> <!-- Name -->
<div class="col-span-6 sm:col-span-4"> <div class="col-span-6 sm:col-span-4">
<x-label for="name" value="{{ __('Name') }}" /> <x-label for="name" value="{{ __('Name') }}" />
@ -88,7 +130,7 @@
{{ __('Saved.') }} {{ __('Saved.') }}
</x-action-message> </x-action-message>
<x-button wire:loading.attr="disabled" wire:target="photo"> <x-button wire:loading.attr="disabled" wire:target="photo,cover">
{{ __('Save') }} {{ __('Save') }}
</x-button> </x-button>
</x-slot> </x-slot>

View File

@ -0,0 +1,35 @@
<x-app-layout>
<div class="sm:-mt-12 dark:bg-gray-800 dark:text-gray-100">
<div>
<img class="h-32 w-full object-cover lg:h-48" src="{{ $user->cover_photo_url }}" alt="{{ $user->name }}">
</div>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="-mt-12 sm:-mt-16 sm:flex sm:items-end sm:space-x-5">
<div class="flex">
<img class="h-24 w-24 rounded-full ring-4 ring-white dark:ring-gray-800 sm:h-32 sm:w-32" src="{{ $user->profile_photo_url }}" alt="{{ $user->name }}" />
</div>
<div class="mt-6 sm:flex sm:min-w-0 sm:flex-1 sm:items-center sm:justify-end sm:space-x-6 sm:pb-1">
<div class="mt-6 min-w-0 flex-1 sm:hidden md:block">
<h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1>
</div>
{{--
<div class="mt-6 flex flex-col justify-stretch space-y-3 sm:flex-row sm:space-x-4 sm:space-y-0">
<button type="button" class="inline-flex justify-center rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-100 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600">
<svg class="-ml-0.5 mr-1.5 h-5 w-5 text-gray-400 dark:text-gray-300" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M3 4a2 2 0 00-2 2v1.161l8.441 4.221a1.25 1.25 0 001.118 0L19 7.162V6a2 2 0 00-2-2H3z" />
<path d="M19 8.839l-7.77 3.885a2.75 2.75 0 01-2.46 0L1 8.839V14a2 2 0 002 2h14a2 2 0 002-2V8.839z" />
</svg>
<span>Message</span>
</button>
</div>
--}}
</div>
</div>
<div class="mt-6 hidden min-w-0 flex-1 sm:block md:hidden">
<h1 class="truncate text-2xl font-bold text-gray-900 dark:text-gray-100">{{ $user->name }}</h1>
</div>
</div>
</div>
</x-app-layout>

View File

@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\ModController; use App\Http\Controllers\ModController;
use App\Http\Controllers\UserController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['auth.banned'])->group(function () { Route::middleware(['auth.banned'])->group(function () {
@ -11,7 +12,11 @@ Route::middleware(['auth.banned'])->group(function () {
Route::controller(ModController::class)->group(function () { Route::controller(ModController::class)->group(function () {
Route::get('/mods', 'index')->name('mods'); Route::get('/mods', 'index')->name('mods');
Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); Route::get('/mod/{mod}/{slug}', 'show')->where(['mod' => '[0-9]+'])->name('mod.show');
});
Route::controller(UserController::class)->group(function () {
Route::get('/user/{user}/{username}', 'show')->where(['user' => '[0-9]+'])->name('user.show');
}); });
Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () { Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () {