diff --git a/app/Actions/Fortify/UpdateUserProfileInformation.php b/app/Actions/Fortify/UpdateUserProfileInformation.php index 9738772..10569dd 100644 --- a/app/Actions/Fortify/UpdateUserProfileInformation.php +++ b/app/Actions/Fortify/UpdateUserProfileInformation.php @@ -21,12 +21,17 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)], 'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'], + 'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'], ])->validateWithBag('updateProfileInformation'); if (isset($input['photo'])) { $user->updateProfilePhoto($input['photo']); } + if (isset($input['cover'])) { + $user->updateCoverPhoto($input['cover']); + } + if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) { $this->updateVerifiedUser($user, $input); diff --git a/app/Http/Controllers/Api/V0/ApiController.php b/app/Http/Controllers/Api/V0/ApiController.php new file mode 100644 index 0000000..588e047 --- /dev/null +++ b/app/Http/Controllers/Api/V0/ApiController.php @@ -0,0 +1,36 @@ +get('include'); + + if (! $param) { + return false; + } + + $includeValues = explode(',', Str::lower($param)); + + if (is_array($relationships)) { + foreach ($relationships as $relationship) { + if (in_array(Str::lower($relationship), $includeValues)) { + return true; + } + } + + return false; + } + + return in_array(Str::lower($relationships), $includeValues); + } +} diff --git a/app/Http/Controllers/Api/V0/ModController.php b/app/Http/Controllers/Api/V0/ModController.php index 2711ce1..9065bbc 100644 --- a/app/Http/Controllers/Api/V0/ModController.php +++ b/app/Http/Controllers/Api/V0/ModController.php @@ -2,20 +2,20 @@ namespace App\Http\Controllers\Api\V0; -use App\Http\Controllers\Controller; +use App\Http\Filters\V1\ModFilter; use App\Http\Requests\Api\V0\StoreModRequest; use App\Http\Requests\Api\V0\UpdateModRequest; use App\Http\Resources\Api\V0\ModResource; use App\Models\Mod; -class ModController extends Controller +class ModController extends ApiController { /** * Display a listing of the resource. */ - public function index() + public function index(ModFilter $filters) { - return ModResource::collection(Mod::paginate()); + return ModResource::collection(Mod::filter($filters)->paginate()); } /** diff --git a/app/Http/Controllers/Api/V0/UsersController.php b/app/Http/Controllers/Api/V0/UsersController.php index d876348..8bf7824 100644 --- a/app/Http/Controllers/Api/V0/UsersController.php +++ b/app/Http/Controllers/Api/V0/UsersController.php @@ -2,20 +2,20 @@ namespace App\Http\Controllers\Api\V0; -use App\Http\Controllers\Controller; +use App\Http\Filters\V1\UserFilter; use App\Http\Requests\Api\V0\StoreUserRequest; use App\Http\Requests\Api\V0\UpdateUserRequest; use App\Http\Resources\Api\V0\UserResource; use App\Models\User; -class UsersController extends Controller +class UsersController extends ApiController { /** * Display a listing of the resource. */ - public function index() + public function index(UserFilter $filters) { - return UserResource::collection(User::paginate()); + return UserResource::collection(User::filter($filters)->paginate()); } /** diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php new file mode 100644 index 0000000..a8e86d5 --- /dev/null +++ b/app/Http/Controllers/UserController.php @@ -0,0 +1,25 @@ +slug() !== $username) { + abort(404); + } + + if ($request->user()?->cannot('view', $user)) { + abort(403); + } + + return view('user.show', compact('user')); + } +} diff --git a/app/Http/Filters/V1/ModFilter.php b/app/Http/Filters/V1/ModFilter.php new file mode 100644 index 0000000..dd64b11 --- /dev/null +++ b/app/Http/Filters/V1/ModFilter.php @@ -0,0 +1,143 @@ +builder->whereIn('id', $ids); + } + + public function hub_id(string $value): Builder + { + $ids = array_map('trim', explode(',', $value)); + + return $this->builder->whereIn('hub_id', $ids); + } + + public function name(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('name', 'like', $like); + } + + public function slug(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('slug', 'like', $like); + } + + public function teaser(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('teaser', 'like', $like); + } + + public function source_code_link(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('source_code_link', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = array_map('trim', explode(',', $value)); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = array_map('trim', explode(',', $value)); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } + + public function published_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = array_map('trim', explode(',', $value)); + if (count($dates) > 1) { + return $this->builder->whereBetween('published_at', $dates); + } + + return $this->builder->whereDate('published_at', $value); + } + + public function featured(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('featured', $value); + } + + public function contains_ads(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ads', $value); + } + + public function contains_ai_content(string $value): Builder + { + // We need to convert the string user input to a boolean, or null if it's not a valid "truthy/falsy" value. + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + // This column is not nullable. + if ($value === null) { + return $this->builder; + } + + return $this->builder->where('contains_ai_content', $value); + } +} diff --git a/app/Http/Filters/V1/QueryFilter.php b/app/Http/Filters/V1/QueryFilter.php new file mode 100644 index 0000000..f636cd7 --- /dev/null +++ b/app/Http/Filters/V1/QueryFilter.php @@ -0,0 +1,61 @@ +request = $request; + } + + public function apply(Builder $builder): Builder + { + $this->builder = $builder; + + foreach ($this->request->all() as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } + + protected function filter(array $filters): Builder + { + foreach ($filters as $attribute => $value) { + if (method_exists($this, $attribute)) { + $this->$attribute($value); + } + } + + return $this->builder; + } + + protected function sort(string $values): Builder + { + $sortables = array_map('trim', explode(',', $values)); + + foreach ($sortables as $sortable) { + $direction = Str::startsWith($sortable, '-') ? 'desc' : 'asc'; + $column = Str::of($sortable)->remove('-')->value(); + + if (in_array($column, $this->sortable)) { + $this->builder->orderBy($column, $direction); + } + } + + return $this->builder; + } +} diff --git a/app/Http/Filters/V1/UserFilter.php b/app/Http/Filters/V1/UserFilter.php new file mode 100644 index 0000000..1eec8fc --- /dev/null +++ b/app/Http/Filters/V1/UserFilter.php @@ -0,0 +1,55 @@ +builder->whereIn('id', $ids); + } + + public function name(string $value): Builder + { + // The API handles the wildcard character as an asterisk (*), but the database uses the percentage sign (%). + $like = Str::replace('*', '%', $value); + + return $this->builder->where('name', 'like', $like); + } + + public function created_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = array_map('trim', explode(',', $value)); + if (count($dates) > 1) { + return $this->builder->whereBetween('created_at', $dates); + } + + return $this->builder->whereDate('created_at', $value); + } + + public function updated_at(string $value): Builder + { + // The API allows for a range of dates to be passed as a comma-separated list. + $dates = array_map('trim', explode(',', $value)); + if (count($dates) > 1) { + return $this->builder->whereBetween('updated_at', $dates); + } + + return $this->builder->whereDate('updated_at', $value); + } +} diff --git a/app/Http/Resources/Api/V0/LicenseResource.php b/app/Http/Resources/Api/V0/LicenseResource.php new file mode 100644 index 0000000..a64ebe2 --- /dev/null +++ b/app/Http/Resources/Api/V0/LicenseResource.php @@ -0,0 +1,25 @@ + 'license', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'link' => $this->link, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/ModResource.php b/app/Http/Resources/Api/V0/ModResource.php index 42fd8d4..d02762d 100644 --- a/app/Http/Resources/Api/V0/ModResource.php +++ b/app/Http/Resources/Api/V0/ModResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\V0; +use App\Http\Controllers\Api\V0\ApiController; use App\Models\Mod; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -18,6 +19,7 @@ class ModResource extends JsonResource 'type' => 'mod', 'id' => $this->id, 'attributes' => [ + 'hub_id' => $this->hub_id, 'name' => $this->name, 'slug' => $this->slug, 'teaser' => $this->teaser, @@ -35,27 +37,48 @@ class ModResource extends JsonResource 'published_at' => $this->published_at, ], 'relationships' => [ - 'users' => [ - 'data' => $this->users->map(fn ($user) => [ + 'users' => $this->users->map(fn ($user) => [ + 'data' => [ 'type' => 'user', 'id' => $user->id, - ])->toArray(), - - // TODO: Provide 'links.self' to user profile - //'links' => ['self' => '#'], - ], - 'license' => [ + ], + 'links' => [ + 'self' => $user->profileUrl(), + ], + ])->toArray(), + 'versions' => $this->versions->map(fn ($version) => [ 'data' => [ - 'type' => 'license', - 'id' => $this->license_id, + 'type' => 'version', + 'id' => $version->id, + ], + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'links' => [ + 'self' => $version->link, + ], + + ])->toArray(), + 'license' => [ + [ + 'data' => [ + 'type' => 'license', + 'id' => $this->license_id, + ], ], ], ], - 'included' => $this->users->map(fn ($user) => new UserResource($user)), - - // TODO: Provide 'included' data for attached 'license': - //new LicenseResource($this->license) - + 'includes' => $this->when( + ApiController::shouldInclude(['users', 'license', 'versions']), + fn () => collect([ + 'users' => $this->users->map(fn ($user) => new UserResource($user)), + 'license' => new LicenseResource($this->license), + 'versions' => $this->versions->map(fn ($version) => new ModVersionResource($version)), + ]) + ->filter(fn ($value, $key) => ApiController::shouldInclude($key)) + ->flatten(1) + ->values() + ), 'links' => [ 'self' => route('mod.show', [ 'mod' => $this->id, diff --git a/app/Http/Resources/Api/V0/ModVersionResource.php b/app/Http/Resources/Api/V0/ModVersionResource.php new file mode 100644 index 0000000..d42008d --- /dev/null +++ b/app/Http/Resources/Api/V0/ModVersionResource.php @@ -0,0 +1,54 @@ + 'mod_version', + 'id' => $this->id, + 'attributes' => [ + 'hub_id' => $this->hub_id, + 'mod_id' => $this->mod_id, + 'version' => $this->version, + + // TODO: This should only be visible on the mod version show route(?) which doesn't exist. + //'description' => $this->when( + // $request->routeIs('api.v0.modversion.show'), + // $this->description + //), + + // TODO: The download link to the version can be placed here, but I'd like to track the number of + // downloads that are made, so we'll need a new route/feature for that. #35 + 'link' => $this->link, + + 'spt_version_id' => $this->spt_version_id, + 'virus_total_link' => $this->virus_total_link, + 'downloads' => $this->downloads, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'published_at' => $this->published_at, + ], + 'relationships' => [ + 'spt_version' => [ + [ + 'data' => [ + 'type' => 'spt_version', + 'id' => $this->spt_version_id, + ], + ], + ], + ], + ]; + } +} diff --git a/app/Http/Resources/Api/V0/UserResource.php b/app/Http/Resources/Api/V0/UserResource.php index f4e2e68..903012f 100644 --- a/app/Http/Resources/Api/V0/UserResource.php +++ b/app/Http/Resources/Api/V0/UserResource.php @@ -2,6 +2,7 @@ namespace App\Http\Resources\Api\V0; +use App\Http\Controllers\Api\V0\ApiController; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -9,9 +10,6 @@ use Illuminate\Http\Resources\Json\JsonResource; /** @mixin User */ class UserResource extends JsonResource { - /** - * Transform the resource into an array. - */ public function toArray(Request $request): array { return [ @@ -21,6 +19,7 @@ class UserResource extends JsonResource 'name' => $this->name, 'user_role_id' => $this->user_role_id, 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ], 'relationships' => [ 'user_role' => [ @@ -30,11 +29,13 @@ class UserResource extends JsonResource ], ], ], - // TODO: Provide 'included' data for attached 'user_role' - //'included' => [new UserRoleResource($this->role)], - - // TODO: Provide 'links.self' to user profile: - //'links' => ['self' => '#'], + 'includes' => $this->when( + ApiController::shouldInclude('user_role'), + new UserRoleResource($this->role) + ), + 'links' => [ + 'self' => $this->profileUrl(), + ], ]; } } diff --git a/app/Http/Resources/Api/V0/UserRoleResource.php b/app/Http/Resources/Api/V0/UserRoleResource.php new file mode 100644 index 0000000..51d5146 --- /dev/null +++ b/app/Http/Resources/Api/V0/UserRoleResource.php @@ -0,0 +1,25 @@ + 'user_role', + 'id' => $this->id, + 'attributes' => [ + 'name' => $this->name, + 'short_name' => $this->short_name, + 'description' => $this->description, + 'color_class' => $this->color_class, + ], + ]; + } +} diff --git a/app/Jobs/ImportHubData.php b/app/Jobs/ImportHubData.php index 5edcd40..74ac2b1 100644 --- a/app/Jobs/ImportHubData.php +++ b/app/Jobs/ImportHubData.php @@ -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 // tables to store the data to save on memory; we don't want this to be a hog. + $this->bringUserAvatarLocal(); $this->bringFileAuthorsLocal(); $this->bringFileOptionsLocal(); $this->bringFileContentLocal(); @@ -58,6 +59,34 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue 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. */ @@ -172,15 +201,33 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue */ 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') ->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') - ->chunkById(250, function (Collection $users) { + ->chunkById(250, function (Collection $users) use ($curl) { $userData = $bannedUsers = $userRanks = []; foreach ($users as $user) { - $userData[] = $this->collectUserData($user); + $userData[] = $this->collectUserData($curl, $user); $bannedUserData = $this->collectBannedUserData($user); if ($bannedUserData) { @@ -197,15 +244,20 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue $this->handleBannedUsers($bannedUsers); $this->handleUserRoles($userRanks); }, 'userID'); + + // Close the cURL handler. + curl_close($curl); } - protected function collectUserData($user): array + protected function collectUserData(CurlHandle $curl, object $user): array { return [ 'hub_id' => (int) $user->userID, 'name' => $user->username, - 'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'), + 'email' => Str::lower($user->email), '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), 'updated_at' => now('UTC')->toDateTimeString(), ]; @@ -224,6 +276,75 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue 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. */ @@ -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 { 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; $relativePath = 'mods/'.$fileName; - // 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 a mod thumbnail. cURL error: '.curl_error($curl)); - - return ''; - } - - // Store the image on the disk. - Storage::disk($disk)->put($relativePath, $image); - - return $relativePath; + return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath); } /** @@ -693,6 +787,7 @@ class ImportHubData implements ShouldBeUnique, ShouldQueue public function failed(Exception $exception): void { // 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_option_values'); DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content'); diff --git a/app/Livewire/Profile/UpdateProfileForm.php b/app/Livewire/Profile/UpdateProfileForm.php new file mode 100644 index 0000000..127e34f --- /dev/null +++ b/app/Livewire/Profile/UpdateProfileForm.php @@ -0,0 +1,76 @@ +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'); + } +} diff --git a/app/Models/License.php b/app/Models/License.php index d67c71e..c678a35 100644 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -11,6 +11,9 @@ class License extends Model { use HasFactory, SoftDeletes; + /** + * The relationship between a license and mod. + */ public function mods(): HasMany { return $this->hasMany(Mod::class); diff --git a/app/Models/Mod.php b/app/Models/Mod.php index 81b7391..193cc17 100644 --- a/app/Models/Mod.php +++ b/app/Models/Mod.php @@ -2,8 +2,10 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Models\Scopes\DisabledScope; use App\Models\Scopes\PublishedScope; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -25,6 +27,9 @@ class Mod extends Model { use HasFactory, Searchable, SoftDeletes; + /** + * Post boot method to configure the model. + */ protected static function booted(): void { // Apply the global scope to exclude disabled mods. @@ -34,18 +39,24 @@ class Mod extends Model } /** - * The users that belong to the mod. + * The relationship between a mod and its users. */ public function users(): BelongsToMany { return $this->belongsToMany(User::class); } + /** + * The relationship between a mod and its license. + */ public function license(): BelongsTo { return $this->belongsTo(License::class); } + /** + * The relationship between a mod and its versions. + */ public function versions(): HasMany { return $this->hasMany(ModVersion::class)->orderByDesc('version'); @@ -62,6 +73,9 @@ class Mod extends Model ]); } + /** + * The relationship between a mod and its last updated version. + */ public function lastUpdatedVersion(): HasOne { return $this->hasOne(ModVersion::class) @@ -69,7 +83,7 @@ class Mod extends Model } /** - * Get the indexable data array for the model. + * The data that is searchable by Scout. */ public function toSearchableArray(): array { @@ -97,11 +111,12 @@ class Mod extends Model { return $this->hasOne(ModVersion::class) ->orderByDesc('version') + ->orderByDesc('updated_at') ->take(1); } /** - * Determine if the model should be searchable. + * Determine if the model instance should be searchable. */ public function shouldBeSearchable(): bool { @@ -109,7 +124,7 @@ class Mod extends Model } /** - * Get the URL to the thumbnail. + * Build the URL to the mod's thumbnail. */ public function thumbnailUrl(): Attribute { @@ -121,7 +136,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 { @@ -131,6 +146,17 @@ class Mod extends Model }; } + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); + } + + /** + * The attributes that should be cast to native types. + */ protected function casts(): array { return [ @@ -142,7 +168,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 { diff --git a/app/Models/ModVersion.php b/app/Models/ModVersion.php index 57669db..faaea23 100644 --- a/app/Models/ModVersion.php +++ b/app/Models/ModVersion.php @@ -19,12 +19,18 @@ class ModVersion extends Model { use HasFactory, SoftDeletes; + /** + * Post boot method to configure the model. + */ protected static function booted(): void { static::addGlobalScope(new DisabledScope); static::addGlobalScope(new PublishedScope); } + /** + * The relationship between a mod version and mod. + */ public function mod(): BelongsTo { return $this->belongsTo(Mod::class); @@ -38,6 +44,9 @@ class ModVersion extends Model return $this->hasMany(ModDependency::class); } + /** + * The relationship between a mod version and SPT version. + */ public function sptVersion(): BelongsTo { return $this->belongsTo(SptVersion::class); diff --git a/app/Models/SptVersion.php b/app/Models/SptVersion.php index 518a308..0a05dd0 100644 --- a/app/Models/SptVersion.php +++ b/app/Models/SptVersion.php @@ -11,6 +11,9 @@ class SptVersion extends Model { use HasFactory, SoftDeletes; + /** + * The relationship between an SPT version and mod version. + */ public function modVersions(): HasMany { return $this->hasMany(ModVersion::class); diff --git a/app/Models/User.php b/app/Models/User.php index 224e5b5..157248b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,9 +2,12 @@ namespace App\Models; +use App\Http\Filters\V1\QueryFilter; use App\Notifications\ResetPassword; use App\Notifications\VerifyEmail; +use App\Traits\HasCoverPhoto; use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -21,6 +24,7 @@ class User extends Authenticatable implements MustVerifyEmail { use Bannable; use HasApiTokens; + use HasCoverPhoto; use HasFactory; use HasProfilePhoto; use Notifiable; @@ -38,11 +42,17 @@ class User extends Authenticatable implements MustVerifyEmail 'profile_photo_url', ]; + /** + * The relationship between a user and their mods. + */ public function mods(): BelongsToMany { return $this->belongsToMany(Mod::class); } + /** + * The data that is searchable by Scout. + */ public function toSearchableArray(): array { return [ @@ -51,28 +61,25 @@ class User extends Authenticatable implements MustVerifyEmail ]; } + /** + * Determine if the model instance should be searchable. + */ public function shouldBeSearchable(): bool { return ! is_null($this->email_verified_at); } - public function assignRole(UserRole $role): bool - { - $this->role()->associate($role); - - return $this->save(); - } - - public function role(): BelongsTo - { - return $this->belongsTo(UserRole::class, 'user_role_id'); - } - + /** + * Check if the user has the role of a moderator. + */ public function isMod(): bool { return Str::lower($this->role?->name) === 'moderator'; } + /** + * Check if the user has the role of an administrator. + */ public function isAdmin(): bool { return Str::lower($this->role?->name) === 'administrator'; @@ -94,12 +101,49 @@ class User extends Authenticatable implements MustVerifyEmail $this->notify(new ResetPassword($token)); } - protected function casts(): array + /** + * Get the relative URL to the user's profile page. + */ + public function profileUrl(): string { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; + return route('user.show', [ + 'user' => $this->id, + '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'); + } + + /** + * Scope a query by applying QueryFilter filters. + */ + public function scopeFilter(Builder $builder, QueryFilter $filters): Builder + { + return $filters->apply($builder); } /** @@ -107,9 +151,17 @@ class User extends Authenticatable implements MustVerifyEmail */ protected function profilePhotoDisk(): string { - return match (config('app.env')) { - 'production' => 'r2', // Cloudflare R2 Storage - default => 'public', // Local - }; + return config('filesystems.asset_upload', 'public'); + } + + /** + * The attributes that should be cast to native types. + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; } } diff --git a/app/Models/UserRole.php b/app/Models/UserRole.php index df600ef..e27e57f 100644 --- a/app/Models/UserRole.php +++ b/app/Models/UserRole.php @@ -10,6 +10,9 @@ class UserRole extends Model { use HasFactory; + /** + * The relationship between a user role and users. + */ public function users(): HasMany { return $this->hasMany(User::class); diff --git a/app/Policies/ModPolicy.php b/app/Policies/ModPolicy.php index e683811..b5e9873 100644 --- a/app/Policies/ModPolicy.php +++ b/app/Policies/ModPolicy.php @@ -8,7 +8,7 @@ use App\Models\User; class ModPolicy { /** - * Determine whether the user can view any models. + * Determine whether the user can view multiple models. */ 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 { diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000..8878cd8 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,47 @@ +isAdmin(); }); + + // Register a number macro to format download numbers. + Number::macro('downloads', function (int|float $number) { + return Number::forHumans( + $number, + $number > 1000000 ? 2 : ($number > 1000 ? 1 : 0), + maxPrecision: null, + abbreviate: true + ); + }); } } diff --git a/app/Traits/HasCoverPhoto.php b/app/Traits/HasCoverPhoto.php new file mode 100644 index 0000000..ce429e3 --- /dev/null +++ b/app/Traits/HasCoverPhoto.php @@ -0,0 +1,72 @@ +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'; + } +} diff --git a/config/filesystems.php b/config/filesystems.php index f51b156..62d033d 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -15,6 +15,18 @@ return [ '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 diff --git a/config/horizon.php b/config/horizon.php index 6a28221..b3a3771 100644 --- a/config/horizon.php +++ b/config/horizon.php @@ -207,7 +207,7 @@ return [ 'maxJobs' => 0, 'memory' => 256, 'tries' => 1, - 'timeout' => 900, // 15 Minutes + 'timeout' => 1500, // 25 Minutes 'nice' => 0, ], ], diff --git a/config/livewire.php b/config/livewire.php index 0d2ba89..c8bd1cd 100644 --- a/config/livewire.php +++ b/config/livewire.php @@ -64,17 +64,17 @@ return [ */ 'temporary_file_upload' => [ - 'disk' => null, // Example: 'local', 's3' | Default: 'default' - 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) - 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' - 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' - 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'disk' => null, + 'rules' => ['file', 'max:12288'], + 'directory' => null, + 'middleware' => 'throttle:5,1', + 'preview_mimes' => [ 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'mov', 'avi', 'wmv', 'mp3', 'm4a', 'jpg', 'jpeg', 'mpga', 'webp', 'wma', ], - 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... - 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + 'max_upload_time' => 5, + 'cleanup' => true, ], /* diff --git a/database/factories/SptVersionFactory.php b/database/factories/SptVersionFactory.php index e25d046..a891069 100644 --- a/database/factories/SptVersionFactory.php +++ b/database/factories/SptVersionFactory.php @@ -13,7 +13,7 @@ class SptVersionFactory extends Factory public function definition(): array { return [ - 'version' => $this->faker->numerify('1.#.#'), + 'version' => $this->faker->numerify('SPT 1.#.#'), 'color_class' => $this->faker->randomElement(['red', 'green', 'emerald', 'lime', 'yellow', 'grey']), 'created_at' => Carbon::now(), 'updated_at' => Carbon::now(), diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index 5a965db..5d26cb2 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -29,7 +29,8 @@ return new class extends Migration ->nullOnDelete() ->cascadeOnUpdate(); $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(); }); diff --git a/resources/views/components/footer.blade.php b/resources/views/components/footer.blade.php index a642506..fdf2261 100644 --- a/resources/views/components/footer.blade.php +++ b/resources/views/components/footer.blade.php @@ -4,6 +4,18 @@

The Forge

+

+ + + + + + + + + + +

diff --git a/resources/views/components/mod-list-stats.blade.php b/resources/views/components/mod-list-stats.blade.php index 456552d..babb578 100644 --- a/resources/views/components/mod-list-stats.blade.php +++ b/resources/views/components/mod-list-stats.blade.php @@ -1,5 +1,5 @@

class(['text-slate-700 dark:text-gray-300 text-sm']) }}> - {{ Number::format($mod->total_downloads) }} downloads + {{ Number::downloads($mod->total_downloads) }} downloads @if(!is_null($mod->created_at)) — Created diff --git a/resources/views/mod/show.blade.php b/resources/views/mod/show.blade.php index cf3f3e8..ef5ab29 100644 --- a/resources/views/mod/show.blade.php +++ b/resources/views/mod/show.blade.php @@ -6,7 +6,7 @@ -

+
{{-- Main Mod Details Card --}} @@ -22,19 +22,25 @@
-

+

{{ $mod->name }} {{ $latestVersion->version }}

- - {{ $latestVersion->sptVersion->version }} -
-

{{ __('Created by') }} {{ $mod->users->pluck('name')->implode(', ') }}

-

{{ $latestVersion->sptVersion->version }} {{ __('Compatible') }}

-

{{ Number::format($mod->total_downloads) }} {{ __('Downloads') }}

+

+ {{ __('Created by') }} + @foreach ($mod->users as $user) + {{ $user->name }}{{ $loop->last ? '' : ',' }} + @endforeach +

+

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

+

+ + {{ $latestVersion->sptVersion->version }} {{ __('Compatible') }} + +

@@ -42,6 +48,11 @@ {{-- Tabs --}}
+ {{-- Mobile Download Button --}} + + + + {{-- Mobile Dropdown --}}
@@ -86,7 +97,7 @@ {{ __('Version') }} {{ $version->version }} -

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

+

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

@@ -98,13 +109,15 @@ {{ __('Created') }} {{ $version->created_at->format("M d, h:m a") }} {{ __('Updated') }} {{ $version->updated_at->format("M d, h:m a") }}
- @if ($version->dependencies->count()) + @if ($version->dependencies->isNotEmpty() && $version->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
{{ __('Dependencies:') }} @foreach ($version->dependencies as $dependency) - - {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) - @if (!$loop->last), @endif + @if ($dependency->resolvedVersion?->mod) + + {{ $dependency->resolvedVersion->mod->name }} ({{ $dependency->resolvedVersion->version }}) + @if (!$loop->last), @endif + @endif @endforeach
@endif @@ -127,8 +140,8 @@ {{-- Right Column --}}
- {{-- Main Download Button --}} - + {{-- Desktop Download Button --}} + @@ -166,7 +179,7 @@

@endif - @if ($latestVersion->dependencies->count()) + @if ($latestVersion->dependencies->isNotEmpty() && $latestVersion->dependencies->contains(fn($dependency) => $dependency->resolvedVersion?->mod))
  • {{ __('Latest Version Dependencies') }}

    diff --git a/resources/views/navigation-menu.blade.php b/resources/views/navigation-menu.blade.php index fa16edf..b4b9757 100644 --- a/resources/views/navigation-menu.blade.php +++ b/resources/views/navigation-menu.blade.php @@ -63,18 +63,24 @@ {{ __('Dashboard') }} -

  • - +
    + + + {{ __('Edit Profile') }} + @if (Laravel\Jetstream\Jetstream::hasApiFeatures()) - diff --git a/resources/views/profile/show.blade.php b/resources/views/profile/show.blade.php index a089e25..76bbc77 100644 --- a/resources/views/profile/show.blade.php +++ b/resources/views/profile/show.blade.php @@ -8,7 +8,7 @@
    @if (Laravel\Fortify\Features::canUpdateProfileInformation()) - @livewire('profile.update-profile-information-form') + @livewire('profile.update-profile-form') @endif diff --git a/resources/views/profile/update-profile-information-form.blade.php b/resources/views/profile/update-profile-information-form.blade.php index 57e560f..74b0f75 100644 --- a/resources/views/profile/update-profile-information-form.blade.php +++ b/resources/views/profile/update-profile-information-form.blade.php @@ -8,7 +8,7 @@ - + @if (Laravel\Jetstream\Jetstream::managesProfilePhotos())
    @@ -24,7 +24,7 @@ reader.readAsDataURL($refs.photo.files[0]); " /> - +
    @@ -52,6 +52,48 @@
    @endif + +
    + + + + + + +
    + {{ $this->user->name }} +
    + + + + + + {{ __('Select A New Cover Photo') }} + + + @if ($this->user->cover_photo_path) + + {{ __('Remove Cover Photo') }} + + @endif + + +
    +
    @@ -88,7 +130,7 @@ {{ __('Saved.') }} - + {{ __('Save') }} diff --git a/resources/views/user/show.blade.php b/resources/views/user/show.blade.php new file mode 100644 index 0000000..8e2874c --- /dev/null +++ b/resources/views/user/show.blade.php @@ -0,0 +1,35 @@ + + +
    +
    + {{ $user->name }} +
    +
    +
    +
    + {{ $user->name }} +
    +
    +
    +

    {{ $user->name }}

    +
    + {{-- +
    + +
    + --}} +
    +
    + +
    +
    + +
    diff --git a/routes/web.php b/routes/web.php index 57d33f6..4641d92 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { @@ -11,7 +12,11 @@ Route::middleware(['auth.banned'])->group(function () { Route::controller(ModController::class)->group(function () { 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 () {