Merge branch 'develop'

This commit is contained in:
Refringe 2024-11-25 16:13:36 -05:00
commit 85c404bd5b
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
173 changed files with 35403 additions and 2776 deletions

View File

@ -9,13 +9,13 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=33306
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=user
DB_PASSWORD=password
SCOUT_DRIVER=null
SCOUT_DRIVER=collection
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
CACHE_STORE=array

View File

@ -89,3 +89,11 @@ DB_HUB_COLLATION=utf8mb4_0900_ai_ci
GITEA_DOMAIN=
GITEA_TOKEN=
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=
# Discord OAuth Credentials
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=${APP_URL}/login/discord/callback

View File

@ -42,3 +42,11 @@ SCOUT_DRIVER=collection
MAIL_MAILER=log
MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com"
MAIL_FROM_NAME="${APP_NAME}"
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=
# Discord OAuth Credentials
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_REDIRECT_URI=${APP_URL}/login/discord/callback

View File

@ -12,7 +12,7 @@ The Forge is a Laravel-based web application that provides a platform for the Si
## Development Environment Setup
We use [Laravel Sail](https://laravel.com/docs/11.x/sail) to mirror the services that are used in our production server in a local development environment. You can see detailed instructions on how to configure the [full development environment](https://github.com/sp-tarkov/forge/wiki/Full-Windows-Dev-Env) or a [lightweight development environment](https://github.com/sp-tarkov/forge/wiki/Light-Windows-Dev-Env) on the project wiki. The full development environment is recommended.
We use [Laravel Sail](https://laravel.com/docs/11.x/sail) to mirror the services that are used in our production server in a local development environment. You can see detailed instructions on how to configure the [full development environment](https://dev.sp-tarkov.com/SPT/forge/wiki/Full-Windows-Dev-Env) or a [lightweight development environment](https://dev.sp-tarkov.com/SPT/forge/wiki/Light-Windows-Dev-Env) on the project wiki. The full development environment is recommended.
### Available Services:
@ -25,13 +25,13 @@ We use [Laravel Sail](https://laravel.com/docs/11.x/sail) to mirror the services
### Notable Routes
| Service | Authentication | Access Via Host |
|----------------------------------|----------------|-----------------------------|
| Service | Authentication | Access Via Host |
|----------------------------------|----------------|----------------------------|
| Laravel Filament Admin Panel | Via User Role | <http://localhost/admin> |
| Redis Queue Management (Horizon) | Via User Role | <http://localhost/horizon> |
| Website Status (Pulse) | Via User Role | <http://localhost/pulse> |
| Meilisearch WebUI | Local Only | <http://localhost:7700> |
| Mailpit WebUI | Local Only | <http://localhost:8025> |
| Meilisearch WebUI | Local Only | <http://localhost:7700> |
| Mailpit WebUI | Local Only | <http://localhost:8025> |
Most of these connection settings should already be configured in the `.env.full` or `.env.light` example files. Simply save one of these (depending on your environment) as `.env` and adjust settings as needed.
@ -80,12 +80,12 @@ For more information on Laravel development, please refer to the [official docum
## Development Discussion
*__Please note__, we are very early in development and will likely not accept work that is not discussed beforehand through the following channels...*
You may propose new features or improvements of existing Forge behavior in [the repository's GitHub discussion board](https://github.com/sp-tarkov/forge/discussions). If you propose a new feature, please be willing to implement at least some of the code that would be needed to complete the feature.
*__Please note__, we are very early in development and will likely not accept work that is not discussed beforehand.*
Informal discussion regarding bugs, new features, and implementation of existing features takes place in the `#website-general` channel of the [Single Player Tarkov Discord server](https://discord.com/invite/Xn9msqQZan). Refringe, the maintainer of Forge, is typically present in the channel on weekdays from 9am-5pm Eastern Time (ET), and sporadically present in the channel at other times.
If you propose a new feature, please be willing to implement at least some of the code that would be needed to complete the feature.
## Which Branch?
The `main` branch is the default branch for Forge. This branch is used for the latest stable release of the site. The `develop` branch is used for the latest development changes. All feature branches should be based on the `develop` branch. All pull requests should target the `develop` branch.

View File

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -4,7 +4,7 @@ on: [ push, pull_request ]
jobs:
security-checker:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@ -12,66 +12,79 @@ jobs:
uses: symfonycorp/security-checker-action@v5
larastan:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo
coverage: none
- name: Get Composer Cache Directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer Dependencies
run: composer install --no-progress --prefer-dist --optimize-autoloader
- name: Prepare Laravel Environment
run: |
php -r "file_exists('.env') || copy('.env.ci', '.env');"
php artisan key:generate
php artisan optimize
- name: Execute Code Static Analysis with Larastan
run: ./vendor/bin/phpstan analyse -c ./phpstan.neon --no-progress --error-format=github
pint-fixer:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo
coverage: none
- name: Get Composer Cache Directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer Dependencies
run: composer install --no-progress --prefer-dist --optimize-autoloader
- name: Prepare Laravel Environment
run: |
php -r "file_exists('.env') || copy('.env.ci', '.env');"
php artisan key:generate
php artisan optimize
- name: Run Pint Code Style Fixer
run: ./vendor/bin/pint
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Pint PHP Style Fixes [no ci]

View File

@ -4,7 +4,7 @@ on: [ push, pull_request ]
jobs:
laravel-tests:
runs-on: ubuntu-24.04
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.3
@ -13,53 +13,65 @@ jobs:
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
ports:
- 33306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
options: --health-cmd="mysql -u user -D testing -ppassword -h mysql -e ''" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo
coverage: none
- name: Get Composer Cache Directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer dependencies
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-
- name: Install Composer Dependencies
run: composer install --no-progress --prefer-dist --optimize-autoloader
- name: Get NPM Cache Directory
id: npm-cache
run: echo "NPM_CACHE_DIR=$(npm config get cache)" >> $GITHUB_ENV
- name: Cache NPM Dependencies
uses: actions/cache@v4
with:
path: ${{ env.NPM_CACHE_DIR }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-node-
- name: Install npm dependencies
run: npm ci
- name: Build Front-end Assets
run: npm run build
- name: Prepare Laravel Environment
run: |
php -r "file_exists('.env') || copy('.env.ci', '.env');"
php artisan key:generate
php artisan optimize
- name: Run Database Migrations
run: php artisan migrate
- name: Link Storage
run: php artisan storage:link
- name: Run Tests
run: php artisan test
- name: Display Laravel Log
if: failure()
run: cat storage/logs/laravel.log

View File

@ -1,40 +0,0 @@
version: 2
updates:
# Composer dependencies (PHP)
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
target-branch: "develop"
labels:
- "dependencies"
assignees:
- "Refringe"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
target-branch: "develop"
labels:
- "dependencies"
assignees:
- "Refringe"
# npm modules (JavaScript)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
target-branch: "develop"
labels:
- "dependencies"
assignees:
- "Refringe"

5
.gitignore vendored
View File

@ -16,6 +16,10 @@ public/build
public/hot
public/storage
storage/*.key
storage/app/livewire-tmp
storage/app/public
!storage/app/public/cover-photos/.gitkeep
!storage/app/public/profile-photos/.gitkeep
vendor
auth.json
frankenphp
@ -24,3 +28,4 @@ Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
.scribe

View File

@ -20,7 +20,7 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', 'max:36', 'unique:users'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',

View File

@ -8,8 +8,6 @@ trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{

View File

@ -13,8 +13,6 @@ class ResetUserPassword implements ResetsUserPasswords
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{

View File

@ -13,8 +13,6 @@ class UpdateUserPassword implements UpdatesUserPasswords
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{

View File

@ -12,13 +12,11 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, mixed> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name' => ['required', 'string', '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'],
'cover' => ['nullable', 'mimes:jpg,jpeg,png', 'max:2048'],

View File

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Jobs\ImportHubDataJob;
use App\Jobs\Import\ImportHubDataJob;
use Illuminate\Console\Command;
class ImportHubCommand extends Command

View File

@ -9,41 +9,77 @@ use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Knuckles\Scribe\Attributes\BodyParam;
use Knuckles\Scribe\Attributes\Response;
use Knuckles\Scribe\Attributes\ResponseField;
use Laravel\Sanctum\PersonalAccessToken;
class AuthController extends Controller
{
use ApiResponses;
/**
* Login
*
* Authenticates the user and returns a read-only API token. This API token can then be saved and used for future
* requests that require authentication. <aside class="warning">This method is made available for mod authors to
* incorporate into their mods so that users can easily authenticate using their own API token. For typical API use,
* you should log into the website, create an API token, and use that token for your API requests.</aside>
*
* @unauthenticated
*
* @group Authentication
*/
#[BodyParam('token_name', 'string', 'The name of the API token.', required: false, example: 'Dynamic API Token')]
#[Response(['message' => 'authenticated', 'data' => ['token' => 'YOUR_API_KEY'], 'status' => 200], status: 200, description: 'Authenticated successfully')]
#[Response(['message' => 'invalid credentials', 'status' => 401], status: 401, description: 'Invalid credentials')]
#[ResponseField('token', description: 'The newly created read-only API token to use for future authenticated requests.')]
public function login(LoginUserRequest $request): JsonResponse
{
$request->validated($request->all());
if (! Auth::attempt($request->only('email', 'password'))) {
return $this->error(__('Invalid credentials'), 401);
return $this->error(__('invalid credentials'), 401);
}
$user = User::firstWhere('email', $request->email);
$tokenName = $request->token_name ?? __('Dynamic API Token');
return $this->success(__('Authenticated'), [
return $this->success(__('authenticated'), [
// Only allowing the 'read' scope to be dynamically created. Can revisit later when writes are possible.
'token' => $user->createToken($tokenName, ['read'])->plainTextToken,
]);
}
/**
* Logout
*
* Destroys the user's current API token, effectively logging them out.
*
* @group Authentication
*/
#[Response(['message' => 'success', 'status' => 200], status: 200, description: 'Token destroyed successfully')]
public function logout(Request $request): JsonResponse
{
/** @var \Laravel\Sanctum\PersonalAccessToken $token */
/** @var PersonalAccessToken $token */
$token = $request->user()->currentAccessToken();
$token->delete();
return $this->success(__('Revoked API token'));
return $this->success(__('success'));
}
/**
* Logout All
*
* Destroys all the user's API tokens, effectively logging everyone out of the account.
*
* @group Authentication
*/
#[Response(['message' => 'success', 'status' => 200], status: 200, description: 'Tokens destroyed successfully')]
public function logoutAll(Request $request): JsonResponse
{
$request->user()->tokens()->delete();
return $this->success(__('Revoked all API tokens'));
return $this->success(__('success'));
}
}

View File

@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
use Illuminate\Support\Str;
use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
class ApiController extends Controller
{
@ -13,7 +15,11 @@ class ApiController extends Controller
*/
public static function shouldInclude(string|array $relationships): bool
{
$param = request()->get('include');
try {
$param = request()->get('include');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
return false;
}
if (! $param) {
return false;

View File

@ -3,50 +3,52 @@
namespace App\Http\Controllers\Api\V0;
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;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Knuckles\Scribe\Attributes\QueryParam;
use Knuckles\Scribe\Attributes\UrlParam;
class ModController extends ApiController
{
/**
* Display a listing of the resource.
* Get Mods
*
* List, filter, and sort basic information about mods.
*
* @group Mods
*/
public function index(ModFilter $filters)
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'users,versions,license')]
#[QueryParam('filter[id]', 'string', 'Filter by the `id`. Select multiple by separating the IDs with a comma.', required: false, example: '5,10,15')]
#[QueryParam('filter[hub_id]', 'string', 'Filter by the `hub_id` attribute. Select multiple by separating the IDs with a comma.', required: false, example: '20')]
#[QueryParam('filter[name]', 'string', 'Filter by the `name` attribute. Use `*` as the wildcard character.', required: false, example: '*SAIN*')]
#[QueryParam('filter[slug]', 'string', 'Filter by the `slug` attribute. Use `*` as the wildcard character.', required: false, example: '*raid-times')]
#[QueryParam('filter[teaser]', 'string', 'Filter by the `teaser` attribute. Use `*` as the wildcard character.', required: false, example: '*weighted*random*times*')]
#[QueryParam('filter[source_code_link]', 'string', 'Filter by the `source_code_link` attribute. Use `*` as the wildcard character.', required: false, example: '*https*.net*')]
#[QueryParam('filter[featured]', 'boolean', 'Filter by the `featured` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[contains_ads]', 'boolean', 'Filter by the `contains_ads` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[contains_ai_content]', 'boolean', 'Filter by the `contains_ai_content` attribute. All "truthy" or "falsy" values are supported.', required: false, example: 'true')]
#[QueryParam('filter[created_at]', 'string', 'Filter by the `created_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[updated_at]', 'string', 'Filter by the `updated_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[published_at]', 'string', 'Filter by the `published_at` attribute. Ranges are possible by seperating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('sort', 'string', 'Sort the results by a comma seperated list of attributes. The default sort direction is ASC, append the attribute name with a minus to sort DESC.', required: false, example: '-featured,name')]
public function index(ModFilter $filters): AnonymousResourceCollection
{
return ModResource::collection(Mod::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
* Get Mod
*
* Display more detailed information about a specific mod.
*
* @group Mods
*/
public function store(StoreModRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Mod $mod)
#[UrlParam('id', 'integer', 'The ID of the mod.', required: true, example: 558)]
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'users,versions,license')]
public function show(Mod $mod): JsonResource
{
return new ModResource($mod);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateModRequest $request, Mod $mod)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Mod $mod)
{
//
}
}

View File

@ -3,50 +3,42 @@
namespace App\Http\Controllers\Api\V0;
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;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Resources\Json\JsonResource;
use Knuckles\Scribe\Attributes\QueryParam;
class UsersController extends ApiController
{
/**
* Display a listing of the resource.
* Get Users
*
* List, filter, and sort basic information about users.
*
* @group Users
*/
public function index(UserFilter $filters)
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'user_role')]
#[QueryParam('filter[id]', 'string', 'Filter by the `id`. Select multiple by separating the IDs with a comma.', required: false, example: '5,10,15')]
#[QueryParam('filter[name]', 'string', 'Filter by the `name` attribute. Use `*` as the wildcard character.', required: false, example: '*fringe')]
#[QueryParam('filter[created_at]', 'string', 'Filter by the `created_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('filter[updated_at]', 'string', 'Filter by the `updated_at` attribute. Ranges are possible by separating the dates with a comma.', required: false, example: '2023-12-31,2024-12-31')]
#[QueryParam('sort', 'string', 'Sort the results by a comma seperated list of attributes. The default sort direction is ASC, append the attribute name with a minus to sort DESC.', required: false, example: 'created_at,-name')]
public function index(UserFilter $filters): AnonymousResourceCollection
{
return UserResource::collection(User::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
* Get User
*
* Display more detailed information about a specific user.
*
* @group Users
*/
public function store(StoreUserRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(User $user)
#[QueryParam('include', 'string', 'The relationships to include within the `includes` key. By default no relationships are automatically included.', required: false, example: 'user_role')]
public function show(User $user): JsonResource
{
return new UserResource($user);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, User $user)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
//
}
}

View File

@ -6,37 +6,36 @@ use App\Http\Requests\ModRequest;
use App\Http\Resources\ModResource;
use App\Models\Mod;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
class ModController extends Controller
{
use AuthorizesRequests;
public function index()
public function index(): View
{
$this->authorize('viewAny', Mod::class);
return view('mod.index');
}
public function store(ModRequest $request)
public function store(ModRequest $request): ModResource
{
$this->authorize('create', Mod::class);
return new ModResource(Mod::create($request->validated()));
}
public function show(int $modId, string $slug)
public function show(int $modId, string $slug): View
{
$mod = Mod::with([
'versions',
'versions.latestSptVersion:id,version,color_class',
'versions.latestSptVersion',
'versions.latestResolvedDependencies',
'versions.latestResolvedDependencies.mod:id,name,slug',
'users:id,name',
'license:id,name,link',
])
->whereHas('latestVersion')
->findOrFail($modId);
'versions.latestResolvedDependencies.mod',
'license',
'users',
])->findOrFail($modId);
if ($mod->slug !== $slug) {
abort(404);
@ -47,7 +46,7 @@ class ModController extends Controller
return view('mod.show', compact(['mod']));
}
public function update(ModRequest $request, Mod $mod)
public function update(ModRequest $request, Mod $mod): ModResource
{
$this->authorize('update', $mod);
@ -56,12 +55,5 @@ class ModController extends Controller
return new ModResource($mod);
}
public function destroy(Mod $mod)
{
$this->authorize('delete', $mod);
$mod->delete();
return response()->json();
}
public function destroy(Mod $mod): void {}
}

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

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers;
use App\Models\OAuthConnection;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as ProviderUser;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse as SymfonyRedirectResponse;
class SocialiteController extends Controller
{
/**
* The providers that are supported.
*/
protected array $providers = ['discord'];
/**
* Redirect the user to the provider's authentication page.
*/
public function redirect(string $provider): SymfonyRedirectResponse
{
if (! in_array($provider, $this->providers)) {
return redirect()->route('login')->withErrors(__('Unsupported OAuth provider.'));
}
$socialiteProvider = Socialite::driver($provider);
if (method_exists($socialiteProvider, 'scopes')) {
return $socialiteProvider->scopes(['identify', 'email'])->redirect();
}
return $socialiteProvider->redirect();
}
/**
* Obtain the user information from the provider.
*/
public function callback(string $provider): RedirectResponse
{
if (! in_array($provider, $this->providers)) {
return redirect()->route('login')->withErrors(__('Unsupported OAuth provider.'));
}
try {
$providerUser = Socialite::driver($provider)->user();
} catch (\Exception $e) {
return redirect()->route('login')->withErrors('Unable to login using '.$provider.'. Please try again.');
}
$user = $this->findOrCreateUser($provider, $providerUser);
Auth::login($user, remember: true);
return redirect()->route('dashboard');
}
protected function findOrCreateUser(string $provider, ProviderUser $providerUser): User
{
$oauthConnection = OAuthConnection::whereProvider($provider)
->whereProviderId($providerUser->getId())
->first();
if ($oauthConnection) {
$oauthConnection->update([
'token' => $providerUser->token ?? '',
'refresh_token' => $providerUser->refreshToken ?? '',
'nickname' => $providerUser->getNickname() ?? '',
'name' => $providerUser->getName() ?? '',
'email' => $providerUser->getEmail() ?? '',
'avatar' => $providerUser->getAvatar() ?? '',
]);
return $oauthConnection->user;
}
// If the username already exists in the database, append a random string to it to ensure uniqueness.
$username = $providerUser->getName() ?? $providerUser->getNickname();
$random = '';
while (User::whereName($username.$random)->exists()) {
$random = '-'.Str::random(5);
}
$username .= $random;
// The user has not connected their account with this OAuth provider before, so a new connection needs to be
// established. Check if the user has an account with the same email address that's passed in from the provider.
// If one exists, connect that account. Otherwise, create a new one.
return DB::transaction(function () use ($providerUser, $provider, $username) {
$user = User::firstOrCreate(['email' => $providerUser->getEmail()], [
'name' => $username,
'password' => null,
]);
$connection = $user->oAuthConnections()->create([
'provider' => $provider,
'provider_id' => $providerUser->getId(),
'token' => $providerUser->token ?? '',
'refresh_token' => $providerUser->refreshToken ?? '',
'nickname' => $providerUser->getNickname() ?? '',
'name' => $providerUser->getName() ?? '',
'email' => $providerUser->getEmail() ?? '',
'avatar' => $providerUser->getAvatar() ?? '',
]);
$this->updateAvatar($user, $connection->avatar);
return $user;
});
}
private function updateAvatar(User $user, string $avatarUrl): void
{
// Determine the disk to use based on the environment.
$disk = match (config('app.env')) {
'production' => 'r2', // Cloudflare R2 Storage
default => 'public', // Local
};
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_URL, $avatarUrl);
$image = curl_exec($curl);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));
return;
}
// Generate a random path for the image and ensure that it doesn't already exist.
do {
$relativePath = User::profilePhotoStoragePath().'/'.Str::random(40).'.webp';
} while (Storage::disk($disk)->exists($relativePath));
// Store the image on the disk.
Storage::disk($disk)->put($relativePath, $image);
// Update the user's profile photo path.
$user->forceFill([
'profile_photo_path' => $relativePath,
])->save();
}
}

View File

@ -5,13 +5,28 @@ namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UserController extends Controller
{
use AuthorizesRequests;
public function show(Request $request, User $user, string $username)
public function show(Request $request, int $userId, string $username): View
{
$user = User::whereId($userId)
->with(['following', 'followers'])
->firstOrFail();
$mods = $user->mods()
->with([
'users',
'latestVersion',
'latestVersion.latestSptVersion',
])
->orderByDesc('created_at')
->paginate(10)
->fragment('mods');
if ($user->slug() !== $username) {
abort(404);
}
@ -20,6 +35,6 @@ class UserController extends Controller
abort(403);
}
return view('user.show', compact('user'));
return view('user.show', compact('user', 'mods'));
}
}

View File

@ -3,7 +3,6 @@
namespace App\Http\Filters;
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@ -15,14 +14,17 @@ class ModFilter
protected Builder $builder;
/**
* The filter that should be applied to the query.
* The filters to apply.
*/
protected array $filters;
/**
* Create a new ModFilter instance.
*/
public function __construct(array $filters)
{
$this->builder = $this->baseQuery();
$this->filters = $filters;
$this->builder = $this->baseQuery();
}
/**
@ -30,21 +32,29 @@ class ModFilter
*/
private function baseQuery(): Builder
{
return Mod::select([
'mods.id',
'mods.name',
'mods.slug',
'mods.teaser',
'mods.thumbnail',
'mods.featured',
'mods.downloads',
'mods.created_at',
])->with([
'users:id,name',
'latestVersion' => function ($query) {
$query->with('latestSptVersion:id,version,color_class');
},
]);
return Mod::query()
->select('mods.*')
->whereExists(function ($query) {
$query->select(DB::raw(1))
->from('mod_versions')
->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id')
->join('spt_versions', 'mod_version_spt_version.spt_version_id', '=', 'spt_versions.id')
->whereColumn('mod_versions.mod_id', 'mods.id')
->where('spt_versions.version', '!=', '0.0.0');
})
->with([
'users:id,name',
'latestVersion',
'latestVersion.latestSptVersion',
]);
}
/**
* Filter the results by the given search term.
*/
private function query(string $term): Builder
{
return $this->builder->whereLike('mods.name', "%{$term}%");
}
/**
@ -58,8 +68,6 @@ class ModFilter
}
}
//dd($this->builder->toRawSql());
return $this->builder;
}
@ -68,34 +76,11 @@ class ModFilter
*/
private function order(string $type): Builder
{
// We order the "recently updated" mods by the ModVersion's updated_at value.
if ($type === 'updated') {
return $this->builder
->joinSub(
ModVersion::select('mod_id', DB::raw('MAX(updated_at) as latest_updated_at'))->groupBy('mod_id'),
'latest_versions',
'mods.id',
'=',
'latest_versions.mod_id'
)
->orderByDesc('latest_versions.latest_updated_at');
}
// By default, we simply order by the column on the mods table/query.
$column = match ($type) {
'downloaded' => 'downloads',
default => 'created_at',
return match ($type) {
'updated' => $this->builder->orderByDesc('mods.updated_at'),
'downloaded' => $this->builder->orderByDesc('mods.downloads'),
default => $this->builder->orderByDesc('mods.created_at'),
};
return $this->builder->orderByDesc($column);
}
/**
* Filter the results by the given search term.
*/
private function query(string $term): Builder
{
return $this->builder->whereLike('name', "%$term%");
}
/**
@ -104,8 +89,8 @@ class ModFilter
private function featured(string $option): Builder
{
return match ($option) {
'exclude' => $this->builder->where('featured', false),
'only' => $this->builder->where('featured', true),
'exclude' => $this->builder->where('mods.featured', false),
'only' => $this->builder->where('mods.featured', true),
default => $this->builder,
};
}
@ -115,28 +100,14 @@ class ModFilter
*/
private function sptVersions(array $versions): Builder
{
// Parse the versions into major, minor, and patch arrays
$parsedVersions = array_map(fn ($version) => [
'major' => (int) explode('.', $version)[0],
'minor' => (int) (explode('.', $version)[1] ?? 0),
'patch' => (int) (explode('.', $version)[2] ?? 0),
], $versions);
[$majorVersions, $minorVersions, $patchVersions] = array_map('array_unique', [
array_column($parsedVersions, 'major'),
array_column($parsedVersions, 'minor'),
array_column($parsedVersions, 'patch'),
]);
return $this->builder
->join('mod_versions as mv', 'mods.id', '=', 'mv.mod_id')
->join('mod_version_spt_version as mvsv', 'mv.id', '=', 'mvsv.mod_version_id')
->join('spt_versions as sv', 'mvsv.spt_version_id', '=', 'sv.id')
->whereIn('sv.version_major', $majorVersions)
->whereIn('sv.version_minor', $minorVersions)
->whereIn('sv.version_patch', $patchVersions)
->where('sv.version', '!=', '0.0.0')
->groupBy('mods.id')
->distinct();
return $this->builder->whereExists(function ($query) use ($versions) {
$query->select(DB::raw(1))
->from('mod_versions')
->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id')
->join('spt_versions', 'mod_version_spt_version.spt_version_id', '=', 'spt_versions.id')
->whereColumn('mod_versions.mod_id', 'mods.id')
->whereIn('spt_versions.version', $versions)
->where('spt_versions.version', '!=', '0.0.0');
});
}
}

View File

@ -3,10 +3,12 @@
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class ModFilter extends QueryFilter
{
/**
* The sortable fields.
*/
protected array $sortable = [
'name',
'slug',
@ -20,124 +22,99 @@ class ModFilter extends QueryFilter
'published_at',
];
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
/**
* Filter by ID.
*/
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
return $this->filterWhereIn('id', $value);
}
/**
* Filter by hub ID.
*/
public function hub_id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('hub_id', $ids);
return $this->filterWhereIn('hub_id', $value);
}
/**
* Filter by name.
*/
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);
return $this->filterByWildcardLike('name', $value);
}
/**
* Filter by slug.
*/
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);
return $this->filterByWildcardLike('slug', $value);
}
/**
* Filter by teaser.
*/
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);
return $this->filterByWildcardLike('teaser', $value);
}
/**
* Filter by source code link.
*/
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);
return $this->filterByWildcardLike('source_code_link', $value);
}
/**
* Filter by created at date.
*/
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);
return $this->filterByDate('created_at', $value);
}
/**
* Filter by updated at date.
*/
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);
return $this->filterByDate('updated_at', $value);
}
/**
* Filter by published at date.
*/
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);
return $this->filterByDate('published_at', $value);
}
/**
* Filter by featured.
*/
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);
return $this->filterByBoolean('featured', $value);
}
/**
* Filter by contains ads.
*/
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);
return $this->filterByBoolean('contains_ads', $value);
}
/**
* Filter by contains AI content.
*/
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);
return $this->filterByBoolean('contains_ai_content', $value);
}
}

View File

@ -2,23 +2,57 @@
namespace App\Http\Filters\V1;
use App\Traits\V1\FilterMethods;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
abstract class QueryFilter
{
/**
* Include general filter methods.
*/
use FilterMethods;
/**
* The query builder instance.
*/
protected Builder $builder;
/**
* The request instance.
*/
protected Request $request;
/**
* The sortable fields.
*/
protected array $sortable = [];
/**
* Create a new QueryFilter instance.
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Iterate over each of the filter options and call the appropriate method if it exists.
*/
public function filter(array $filters): Builder
{
foreach ($filters as $attribute => $value) {
if (method_exists($this, $attribute)) {
$this->$attribute($value);
}
}
return $this->builder;
}
/**
* Iterate over all request data and call the appropriate method if it exists.
*/
public function apply(Builder $builder): Builder
{
$this->builder = $builder;
@ -31,31 +65,4 @@ abstract class QueryFilter
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;
}
}

View File

@ -3,53 +3,47 @@
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
class UserFilter extends QueryFilter
{
/**
* The sortable fields.
*/
protected array $sortable = [
'name',
'created_at',
'updated_at',
];
// TODO: Many of these are repeated across UserFilter and ModFilter. Consider refactoring into a shared trait.
// Also, consider using common filter types and making the field names dynamic.
/**
* Filter by ID.
*/
public function id(string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn('id', $ids);
return $this->filterWhereIn('id', $value);
}
/**
* Filter by name.
*/
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);
return $this->filterByWildcardLike('name', $value);
}
/**
* Filter by created at date.
*/
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);
return $this->filterByDate('created_at', $value);
}
/**
* Filter by updated at date.
*/
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);
return $this->filterByDate('updated_at', $value);
}
}

View File

@ -19,8 +19,6 @@ class StoreModRequest extends FormRequest
*/
public function rules(): array
{
return [
//
];
return [];
}
}

View File

@ -19,8 +19,6 @@ class StoreUserRequest extends FormRequest
*/
public function rules(): array
{
return [
//
];
return [];
}
}

View File

@ -19,8 +19,6 @@ class UpdateModRequest extends FormRequest
*/
public function rules(): array
{
return [
//
];
return [];
}
}

View File

@ -19,8 +19,6 @@ class UpdateUserRequest extends FormRequest
*/
public function rules(): array
{
return [
//
];
return [];
}
}

View File

@ -6,6 +6,9 @@ use Illuminate\Foundation\Http\FormRequest;
class ModRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
@ -18,6 +21,9 @@ class ModRequest extends FormRequest
];
}
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;

View File

@ -9,6 +9,9 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin License */
class LicenseResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [

View File

@ -15,6 +15,8 @@ class ModResource extends JsonResource
*/
public function toArray(Request $request): array
{
$this->load(['users', 'versions', 'license']);
return [
'type' => 'mod',
'id' => $this->id,
@ -51,11 +53,8 @@ class ModResource extends JsonResource
'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,
'self' => $version->downloadUrl(absolute: true),
],
])->toArray(),

View File

@ -28,11 +28,7 @@ class ModVersionResource extends JsonResource
// $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,
'link' => $this->downloadUrl(absolute: true),
'virus_total_link' => $this->virus_total_link,
'downloads' => $this->downloads,
'created_at' => $this->created_at,
@ -44,7 +40,6 @@ class ModVersionResource extends JsonResource
[
'data' => [
'type' => 'spt_version',
'id' => $this->spt_version_id,
],
],
],

View File

@ -10,8 +10,13 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin User */
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
$this->load('role');
return [
'type' => 'user',
'id' => $this->id,
@ -31,7 +36,7 @@ class UserResource extends JsonResource
],
'includes' => $this->when(
ApiController::shouldInclude('user_role'),
new UserRoleResource($this->role)
new UserRoleResource($this->role),
),
'links' => [
'self' => $this->profileUrl(),

View File

@ -9,6 +9,9 @@ use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin UserRole */
class UserRoleResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [

View File

@ -2,12 +2,16 @@
namespace App\Http\Resources;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\License */
/** @mixin License */
class LicenseResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [

View File

@ -2,12 +2,16 @@
namespace App\Http\Resources;
use App\Models\Mod;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Mod */
/** @mixin Mod */
class ModResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [

View File

@ -2,12 +2,16 @@
namespace App\Http\Resources;
use App\Models\ModVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\ModVersion */
/** @mixin ModVersion */
class ModVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
@ -19,10 +23,7 @@ class ModVersionResource extends JsonResource
'description' => $this->description,
'virus_total_link' => $this->virus_total_link,
'downloads' => $this->downloads,
'mod_id' => $this->mod_id,
'spt_version_id' => $this->spt_version_id,
'mod' => new ModResource($this->whenLoaded('mod')),
'sptVersion' => new SptVersionResource($this->whenLoaded('sptVersion')),
];

View File

@ -2,12 +2,16 @@
namespace App\Http\Resources;
use App\Models\SptVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\SptVersion */
/** @mixin SptVersion */
class SptVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [

View File

@ -0,0 +1,23 @@
<?php
namespace App\Jobs\Import\DataTransferObjects;
class HubUser
{
public function __construct(
public int $userID,
public string $username,
public string $email,
public string $password,
public string $registrationDate,
public ?bool $banned,
public ?string $banReason,
public ?string $banExpires,
public ?string $coverPhotoHash,
public ?string $coverPhotoExtension,
public ?int $rankID,
public ?string $rankTitle,
) {
//
}
}

View File

@ -1,13 +1,16 @@
<?php
namespace App\Jobs;
namespace App\Jobs\Import;
use App\Exceptions\InvalidVersionNumberException;
use App\Jobs\Import\DataTransferObjects\HubUser;
use App\Models\License;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Models\User;
use App\Models\UserRole;
use App\Support\Version;
use Carbon\Carbon;
use CurlHandle;
use Exception;
@ -27,6 +30,7 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use League\HTMLToMarkdown\HtmlConverter;
use Stevebauman\Purify\Facades\Purify;
use Throwable;
class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
@ -37,6 +41,7 @@ class ImportHubDataJob 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->bringUserOptionsLocal();
$this->bringFileAuthorsLocal();
$this->bringFileOptionsLocal();
$this->bringFileContentLocal();
@ -46,6 +51,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
// Begin to import the data into the permanent local database tables.
$this->importUsers();
$this->importUserFollows();
$this->importLicenses();
$this->importSptVersions();
$this->importMods();
@ -97,6 +103,35 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
});
}
/**
* Bring the user options table from the Hub database to the local database temporary table.
*/
private function bringUserOptionsLocal(): void
{
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_user_options_values');
DB::statement('CREATE TEMPORARY TABLE temp_user_options_values (
userID INT,
about LONGTEXT
) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci');
DB::connection('mysql_hub')
->table('wcf1_user_option_value')
->orderBy('userID')
->chunk(200, function ($options) {
$insertData = [];
foreach ($options as $option) {
$insertData[] = [
'userID' => (int) $option->userID,
'about' => $option->userOption1,
];
}
if ($insertData) {
DB::table('temp_user_options_values')->insert($insertData);
}
});
}
/**
* Bring the file authors from the Hub database to the local database temporary table.
*/
@ -309,14 +344,29 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$userData = $bannedUsers = $userRanks = [];
foreach ($users as $user) {
$userData[] = $this->collectUserData($curl, $user);
$hubUser = new HubUser(
$user->userID,
$user->username,
$user->email,
$user->password,
$user->registrationDate,
$user->banned,
$user->banReason,
$user->banExpires,
$user->coverPhotoHash,
$user->coverPhotoExtension,
$user->rankID,
$user->rankTitle
);
$bannedUserData = $this->collectBannedUserData($user);
$userData[] = $this->collectUserData($curl, $hubUser);
$bannedUserData = $this->collectBannedUserData($hubUser);
if ($bannedUserData) {
$bannedUsers[] = $bannedUserData;
}
$userRankData = $this->collectUserRankData($user);
$userRankData = $this->collectUserRankData($hubUser);
if ($userRankData) {
$userRanks[] = $userRankData;
}
@ -331,16 +381,20 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
curl_close($curl);
}
protected function collectUserData(CurlHandle $curl, object $user): array
/**
* Build an array of user data ready to be inserted into the local database.
*/
protected function collectUserData(CurlHandle $curl, HubUser $hubUser): array
{
return [
'hub_id' => (int) $user->userID,
'name' => $user->username,
'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),
'hub_id' => (int) $hubUser->userID,
'name' => $hubUser->username,
'email' => Str::lower($hubUser->email),
'password' => $this->cleanPasswordHash($hubUser->password),
'about' => $this->fetchUserAbout($hubUser->userID),
'profile_photo_path' => $this->fetchUserAvatar($curl, $hubUser),
'cover_photo_path' => $this->fetchUserCoverPhoto($curl, $hubUser),
'created_at' => $this->cleanRegistrationDate($hubUser->registrationDate),
'updated_at' => now('UTC')->toDateTimeString(),
];
}
@ -358,13 +412,39 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
return str_starts_with($clean, '$2') ? $clean : '';
}
/**
* Fetch the user about text from the temporary table.
*/
private function fetchUserAbout(int $userID): string
{
$about = DB::table('temp_user_options_values')
->where('userID', $userID)
->limit(1)
->value('about');
return $this->cleanHubContent($about ?? '');
}
/**
* Convert the mod description from WoltHub flavoured HTML to Markdown.
*/
protected function cleanHubContent(string $dirty): string
{
// Alright, hear me out... Shut up.
$converter = new HtmlConverter;
$clean = Purify::clean($dirty);
return $converter->convert($clean);
}
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserAvatar(CurlHandle $curl, object $user): string
protected function fetchUserAvatar(CurlHandle $curl, HubUser $hubUser): string
{
// Fetch the user's avatar data from the temporary table.
$avatar = DB::table('temp_user_avatar')->where('userID', $user->userID)->first();
$avatar = DB::table('temp_user_avatar')->where('userID', $hubUser->userID)->first();
if (! $avatar) {
return '';
@ -373,7 +453,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$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;
$relativePath = User::profilePhotoStoragePath().'/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
}
@ -413,15 +493,15 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserCoverPhoto(CurlHandle $curl, object $user): string
protected function fetchUserCoverPhoto(CurlHandle $curl, HubUser $hubUser): string
{
if (empty($user->coverPhotoHash) || empty($user->coverPhotoExtension)) {
if (empty($hubUser->coverPhotoHash) || empty($hubUser->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;
$hashShort = substr($hubUser->coverPhotoHash, 0, 2);
$fileName = $hubUser->coverPhotoHash.'.'.$hubUser->coverPhotoExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$hubUser->userID.'-'.$fileName;
$relativePath = 'user-covers/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
@ -445,13 +525,13 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Build an array of banned user data ready to be inserted into the local database.
*/
protected function collectBannedUserData($user): ?array
protected function collectBannedUserData(HubUser $hubUser): ?array
{
if ($user->banned) {
if ($hubUser->banned) {
return [
'hub_id' => (int) $user->userID,
'comment' => $user->banReason ?? '',
'expired_at' => $this->cleanUnbannedAtDate($user->banExpires),
'hub_id' => (int) $hubUser->userID,
'comment' => $hubUser->banReason ?? '',
'expired_at' => $this->cleanUnbannedAtDate($hubUser->banExpires),
];
}
@ -498,12 +578,15 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
}
}
protected function collectUserRankData($user): ?array
/**
* Build an array of user rank data ready to be inserted into the local database.
*/
protected function collectUserRankData(HubUser $hubUser): ?array
{
if ($user->rankID && $user->rankTitle) {
if ($hubUser->rankID && $hubUser->rankTitle) {
return [
'hub_id' => (int) $user->userID,
'title' => $user->rankTitle,
'hub_id' => (int) $hubUser->userID,
'title' => $hubUser->rankTitle,
];
}
@ -513,7 +596,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Insert or update the users in the local database.
*/
protected function upsertUsers($usersData): void
protected function upsertUsers(array $usersData): void
{
if (! empty($usersData)) {
DB::table('users')->upsert($usersData, ['hub_id'], [
@ -529,7 +612,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch the hub-banned users from the local database and ban them locally.
*/
protected function handleBannedUsers($bannedUsers): void
protected function handleBannedUsers(array $bannedUsers): void
{
foreach ($bannedUsers as $bannedUser) {
$user = User::whereHubId($bannedUser['hub_id'])->first();
@ -543,7 +626,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch or create the user ranks in the local database and assign them to the users.
*/
protected function handleUserRoles($userRanks): void
protected function handleUserRoles(array $userRanks): void
{
foreach ($userRanks as $userRank) {
$roleName = Str::ucfirst(Str::afterLast($userRank['title'], '.'));
@ -587,6 +670,37 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
protected function importUserFollows(): void
{
$followsGroupedByFollower = [];
DB::connection('mysql_hub')
->table('wcf1_user_follow')
->select(['followID', 'userID', 'followUserID', 'time'])
->chunkById(100, function (Collection $follows) use (&$followsGroupedByFollower) {
foreach ($follows as $follow) {
$followerId = User::whereHubId($follow->userID)->value('id');
$followingId = User::whereHubId($follow->followUserID)->value('id');
if (! $followerId || ! $followingId) {
continue;
}
$followsGroupedByFollower[$followerId][$followingId] = [
'created_at' => Carbon::parse($follow->time, 'UTC'),
'updated_at' => Carbon::parse($follow->time, 'UTC'),
];
}
}, 'followID');
foreach ($followsGroupedByFollower as $followerId => $followings) {
$user = User::find($followerId);
if ($user) {
$user->following()->sync($followings);
}
}
}
/**
* Import the licenses from the Hub database to the local database.
*/
@ -861,19 +975,6 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
curl_close($curl);
}
/**
* Convert the mod description from WoltHub flavoured HTML to Markdown.
*/
protected function cleanHubContent(string $dirty): string
{
// Alright, hear me out... Shut up.
$converter = new HtmlConverter;
$clean = Purify::clean($dirty);
return $converter->convert($clean);
}
/**
* Fetch the mod thumbnail from the Hub and store it anew.
*/
@ -934,10 +1035,20 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$sptVersionTemp = DB::table('temp_spt_version_tags')->where('hub_id', $versionLabel->labelID)->value('version');
$sptVersionConstraint = $this->extractSemanticVersion($sptVersionTemp, appendPatch: true) ?? '0.0.0';
try {
$modVersion = new Version($version->versionNumber);
} catch (InvalidVersionNumberException $e) {
$modVersion = new Version('0.0.0');
}
$insertData[] = [
'hub_id' => (int) $version->versionID,
'mod_id' => $modId,
'version' => $this->extractSemanticVersion($version->versionNumber) ?? '0.0.0',
'version' => $modVersion,
'version_major' => $modVersion->getMajor(),
'version_minor' => $modVersion->getMinor(),
'version_patch' => $modVersion->getPatch(),
'version_pre_release' => $modVersion->getPreRelease(),
'description' => $this->cleanHubContent($versionContent->description ?? ''),
'link' => $version->downloadURL,
'spt_version_constraint' => $sptVersionConstraint,
@ -984,10 +1095,11 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* The job failed to process.
*/
public function failed(Exception $exception): void
public function failed(Throwable $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_user_options_values');
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');

View File

@ -4,8 +4,10 @@ namespace App\Livewire;
use App\Models\Mod;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class GlobalSearch extends Component
@ -16,20 +18,26 @@ class GlobalSearch extends Component
public string $query = '';
/**
* Whether to show the search result dropdown.
* The search results.
*/
public bool $showDropdown = false;
#[Locked]
public array $result = [];
/**
* Whether to show the "no results found" message.
* The total number of search results.
*/
public bool $noResults = false;
#[Locked]
public int $count = 0;
/**
* Render the component.
*/
public function render(): View
{
return view('livewire.global-search', [
'results' => $this->executeSearch($this->query),
]);
$this->result = $this->executeSearch($this->query);
$this->count = $this->countTotalResults($this->result);
return view('livewire.global-search');
}
/**
@ -38,39 +46,40 @@ class GlobalSearch extends Component
protected function executeSearch(string $query): array
{
$query = Str::trim($query);
$results = ['data' => [], 'total' => 0];
if (Str::length($query)) {
$results['data'] = [
'user' => collect(User::search($query)->raw()['hits']),
'mod' => collect(Mod::search($query)->raw()['hits']),
if (Str::length($query) > 0) {
return [
'user' => $this->fetchUserResults($query),
'mod' => $this->fetchModResults($query),
];
$results['total'] = $this->countTotalResults($results['data']);
}
$this->showDropdown = Str::length($query) > 0;
$this->noResults = $results['total'] === 0 && $this->showDropdown;
return [];
}
return $results;
/**
* Fetch the user search results.
*/
protected function fetchUserResults(string $query): Collection
{
return collect(User::search($query)->raw()['hits']);
}
/**
* Fetch the mod search results.
*/
protected function fetchModResults(string $query): Collection
{
return collect(Mod::search($query)->raw()['hits']);
}
/**
* Count the total number of results across all models.
*/
protected function countTotalResults($results): int
protected function countTotalResults(array $results): int
{
return collect($results)->reduce(function ($carry, $result) {
return collect($results)->reduce(function (int $carry, Collection $result) {
return $carry + $result->count();
}, 0);
}
/**
* Clear the search query and hide the dropdown.
*/
public function clearSearch(): void
{
$this->query = '';
$this->showDropdown = false;
$this->noResults = false;
}
}

View File

@ -4,62 +4,87 @@ namespace App\Livewire\Mod;
use App\Http\Filters\ModFilter;
use App\Models\SptVersion;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Session;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
class Listing extends Component
{
use WithPagination;
/**
* The search query value.
*/
#[Url]
#[Session]
#[Url]
public string $query = '';
/**
* The sort order value.
*/
#[Url]
#[Session]
#[Url]
public string $order = 'created';
/**
* The number of results to show on a single page.
*/
#[Session]
#[Url]
public int $perPage = 12;
/**
* The options that are available for the per page setting.
*/
#[Locked]
public array $perPageOptions = [6, 12, 24, 50];
/**
* The SPT versions filter value.
*/
#[Url]
#[Session]
#[Url]
public array $sptVersions = [];
/**
* The featured filter value.
*/
#[Url]
#[Session]
#[Url]
public string $featured = 'include';
/**
* The available SPT versions.
*
* @var Collection<int, SptVersion>
*/
public Collection $availableSptVersions;
public Collection $activeSptVersions;
/**
* The component mount method, run only once when the component is mounted.
*/
public function mount(): void
{
$this->availableSptVersions = $this->availableSptVersions ?? Cache::remember('available-spt-versions', 60 * 60, function () {
$this->activeSptVersions = $this->activeSptVersions ?? Cache::remember('active-spt-versions', 60 * 60, function () {
return SptVersion::getVersionsForLastThreeMinors();
});
$this->sptVersions = $this->sptVersions ?? $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->sptVersions = $this->sptVersions ?? $this->getDefaultSptVersions();
}
/**
* Get the default values for the SPT Versions filter.
*/
protected function getDefaultSptVersions(): array
{
return $this->getLatestMinorVersions()->pluck('version')->toArray();
}
/**
@ -67,7 +92,7 @@ class Index extends Component
*/
public function getLatestMinorVersions(): Collection
{
return $this->availableSptVersions->filter(function (SptVersion $sptVersion) {
return $this->activeSptVersions->filter(function (SptVersion $sptVersion) {
return $sptVersion->isLatestMinor();
});
}
@ -77,6 +102,8 @@ class Index extends Component
*/
public function render(): View
{
$this->validatePerPage();
// Fetch the mods using the filters saved to the component properties.
$filters = [
'query' => $this->query,
@ -84,14 +111,40 @@ class Index extends Component
'order' => $this->order,
'sptVersions' => $this->sptVersions,
];
$mods = (new ModFilter($filters))->apply()->paginate(16);
// Check if the current page is greater than the last page. Redirect if it is.
$mods = (new ModFilter($filters))->apply()->paginate($this->perPage);
$this->redirectOutOfBoundsPage($mods);
return view('livewire.mod.listing', compact('mods'));
}
/**
* Validate that the option selected is an option that is available by setting it to the closest available version.
*/
public function validatePerPage(): void
{
$this->perPage = collect($this->perPageOptions)->pipe(function ($data) {
$closest = null;
foreach ($data as $item) {
if ($closest === null || abs($this->perPage - $closest) > abs($item - $this->perPage)) {
$closest = $item;
}
}
return $closest;
});
}
/**
* Check if the current page is greater than the last page. Redirect if it is.
*/
private function redirectOutOfBoundsPage(LengthAwarePaginator $mods): void
{
if ($mods->currentPage() > $mods->lastPage()) {
$this->redirectRoute('mods', ['page' => $mods->lastPage()]);
}
return view('livewire.mod.index', compact('mods'));
}
/**
@ -100,11 +153,8 @@ class Index extends Component
public function resetFilters(): void
{
$this->query = '';
$this->sptVersions = $this->getLatestMinorVersions()->pluck('version')->toArray();
$this->sptVersions = $this->getDefaultSptVersions();
$this->featured = 'include';
// Clear local storage
$this->dispatch('clear-filters');
}
/**

View File

@ -0,0 +1,98 @@
<?php
namespace App\Livewire\Profile;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
class ManageOAuthConnections extends Component
{
use AuthorizesRequests;
/**
* Store the current user.
*/
#[Locked]
public $user;
/**
* Controls the confirmation modal visibility.
*/
public $confirmingConnectionDeletion = false;
/**
* Stores the ID of the connection to be deleted.
*/
#[Locked]
public $selectedConnectionId;
/**
* Initializes the component by loading the user's OAuth connections.
*/
public function mount(): void
{
$this->setName('profile.manage-oauth-connections');
$this->user = auth()->user();
}
/**
* Sets up the deletion confirmation.
*/
public function confirmConnectionDeletion($connectionId): void
{
$this->confirmingConnectionDeletion = true;
$this->selectedConnectionId = $connectionId;
}
/**
* Deletes the selected OAuth connection.
*/
public function deleteConnection(): void
{
$connection = $this->user->oauthConnections()->find($this->selectedConnectionId);
// Ensure the user is authorized to delete the connection.
$this->authorize('delete', $connection);
// The user must have a password set before removing an OAuth connection.
if ($this->user->password === null) {
$this->addError('password_required', __('You must set a password before removing an OAuth connection.'));
$this->confirmingConnectionDeletion = false;
return;
}
if ($connection) {
$connection->delete();
$this->user->refresh();
$this->confirmingConnectionDeletion = false;
$this->selectedConnectionId = null;
session()->flash('status', __('OAuth connection removed successfully.'));
} else {
session()->flash('error', __('OAuth connection not found.'));
}
}
/**
* Refreshes the user instance.
*/
#[On('saved')]
public function refreshUser(): void
{
$this->user->refresh();
}
/**
* Renders the component view.
*/
public function render(): View
{
return view('livewire.profile.manage-oauth-connections');
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Profile;
use App\Actions\Fortify\PasswordValidationRules;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
use Laravel\Jetstream\Http\Livewire\UpdatePasswordForm as JetstreamUpdatePasswordForm;
use Override;
class UpdatePasswordForm extends JetstreamUpdatePasswordForm
{
use PasswordValidationRules;
/**
* Update the user's password.
*
* This method has been overwritten to allow a user that has a null password to set a password for their account
* without needing to provide their current password. This is useful for users that have been created using OAuth.
*/
#[Override]
public function updatePassword(UpdatesUserPasswords $updater): void
{
$this->resetErrorBag();
$user = Auth::user();
if ($user->password !== null) {
parent::updatePassword($updater);
} else {
// User has a null password. Allow them to set a new password without their current password.
Validator::make($this->state, [
'password' => $this->passwordRules(),
])->validateWithBag('updatePassword');
auth()->user()->forceFill([
'password' => Hash::make($this->state['password']),
])->save();
$this->state = [
'current_password' => '',
'password' => '',
'password_confirmation' => '',
];
$this->dispatch('saved');
}
}
}

View File

@ -12,8 +12,6 @@ class UpdateProfileForm extends UpdateProfileInformationForm
{
/**
* The new cover photo for the user.
*
* @var mixed
*/
public $cover;

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\User;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Component;
class FollowButtons extends Component
{
/**
* The ID of the user whose profile is being viewed.
*/
#[Locked]
public int $profileUserId;
/**
* Whether the authenticated user is currently following the profile user.
*/
#[Locked]
public bool $isFollowing;
/**
* Action to follow a user.
*/
public function follow(): void
{
auth()->user()->follow($this->profileUserId);
$this->isFollowing = true;
$this->dispatch('user-follow-change');
}
/**
* Action to unfollow a user.
*/
public function unfollow(): void
{
auth()->user()->unfollow($this->profileUserId);
$this->isFollowing = false;
$this->dispatch('user-follow-change');
}
/**
* Render the component.
*/
public function render(): View
{
return view('livewire.user.follow-buttons');
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace App\Livewire\User;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
class FollowCard extends Component
{
/**
* The type of user follow relationship to display.
* Currently, either "followers" or "following".
*/
#[Locked]
public string $relationship;
/**
* The title of the card.
*/
public string $title;
/**
* The message to display when there are no results.
*/
public string $emptyMessage;
/**
* The title of the dialog.
*/
public string $dialogTitle;
/**
* The user data to display in the card.
*/
#[Locked]
public array $display = [];
/**
* The limited user data to display in the card.
*/
#[Locked]
public array $displayLimit = [];
/**
* The maximum number of users to display on the card.
*/
#[Locked]
public int $limit = 4;
/**
* Whether to show all users in a model dialog.
*/
public bool $showFollowDialog = false;
/**
* The user whose profile is being viewed.
*/
#[Locked]
public User $profileUser;
/**
* A collection of user IDs that the auth user follows.
*/
#[Locked]
public Collection $authFollowIds;
/**
* The profile user's followers (or following).
*/
#[Locked]
public Collection $followUsers;
/**
* The events the component should listen for.
*/
protected $listeners = ['refreshComponent' => '$refresh'];
/**
* The number of users being displayed.
*/
#[Computed]
public function followUsersCount(): int
{
return $this->followUsers->count();
}
/**
* Called when the component is initialized.
*/
public function mount(): void
{
$this->setTitle();
$this->setEmptyMessage();
$this->setDialogTitle();
}
/**
* Set the title of the card based on the relationship.
*/
private function setTitle(): void
{
$this->title = match ($this->relationship) {
'followers' => __('Followers'),
'following' => __('Following'),
default => __('Users'),
};
}
/**
* Set the empty message based on the relationship.
*/
private function setEmptyMessage(): void
{
$this->emptyMessage = match ($this->relationship) {
'followers' => __('No followers yet.'),
'following' => __('Not yet following anyone.'),
default => __('No users found.'),
};
}
/**
* Set the dialog title based on the relationship.
*/
private function setDialogTitle(): void
{
$this->dialogTitle = match ($this->relationship) {
'followers' => 'User :name has these followers:',
'following' => 'User :name is following:',
default => 'Users:',
};
}
/**
* Render the component.
*/
public function render(): View
{
$this->populateFollowUsers();
return view('livewire.user.follow-card');
}
/**
* Called when the user follows or unfollows a user.
*/
#[On('auth-follow-change')]
public function populateFollowUsers(): void
{
// Update the collection of profile user's followers (or following).
$this->followUsers = $this->profileUser->{$this->relationship}()->get();
$this->dispatch('refreshComponent');
}
/**
* Toggle showing the follow dialog.
*/
public function toggleFollowDialog(): void
{
$this->showFollowDialog = ! $this->showFollowDialog;
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\User;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
class FollowCards extends Component
{
/**
* The user account that is being viewed.
*/
#[Locked]
public User $profileUser;
/**
* A collection of user IDs that the auth user follows.
*/
#[Locked]
public Collection $authFollowIds;
/**
* Render the component.
*/
public function render(): View
{
$this->updateAuthFollowIds();
return view('livewire.user.follow-cards');
}
/**
* Called when the user follows or unfollows a user.
*/
#[On('user-follow-change')]
public function updateAuthFollowIds(): void
{
// Fetch IDs of all users the authenticated user is following.
$this->authFollowIds = collect();
$authUser = auth()->user();
if ($authUser) {
$this->authFollowIds = $authUser->following()->pluck('following_id');
}
$this->dispatch('auth-follow-change');
}
}

View File

@ -9,13 +9,30 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class License extends Model
{
use HasFactory, SoftDeletes;
use HasFactory;
use SoftDeletes;
/**
* The relationship between a license and mod.
*
* @return HasMany<Mod>
*/
public function mods(): HasMany
{
return $this->hasMany(Mod::class);
return $this->hasMany(Mod::class)
->chaperone();
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'hub_id' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
}
}

View File

@ -14,28 +14,23 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
/**
* @property int $id
* @property string $name
* @property string $slug
*/
class Mod extends Model
{
use HasFactory, Searchable, SoftDeletes;
use HasFactory;
use Searchable;
use SoftDeletes;
/**
* Post boot method to configure the model.
*/
protected static function booted(): void
{
// Apply the global scope to exclude disabled mods.
static::addGlobalScope(new DisabledScope);
// Apply the global scope to exclude non-published mods.
static::addGlobalScope(new PublishedScope);
}
@ -48,8 +43,24 @@ class Mod extends Model
$this->saveQuietly();
}
/**
* Build the URL to download the latest version of this mod.
*/
public function downloadUrl(bool $absolute = false): string
{
$this->load('latestVersion');
return route('mod.version.download', [
$this->id,
$this->slug,
$this->latestVersion->version,
], absolute: $absolute);
}
/**
* The relationship between a mod and its users.
*
* @return BelongsToMany<User>
*/
public function users(): BelongsToMany
{
@ -58,30 +69,40 @@ class Mod extends Model
/**
* The relationship between a mod and its license.
*
* @return BelongsTo<License, Mod>
*/
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
/**
* The relationship between a mod and its last updated version.
*
* @return HasOne<ModVersion>
*/
public function latestUpdatedVersion(): HasOne
{
return $this->versions()
->one()
->ofMany('updated_at', 'max')
->chaperone();
}
/**
* The relationship between a mod and its versions.
*
* @return HasMany<ModVersion>
*/
public function versions(): HasMany
{
return $this->hasMany(ModVersion::class)
->whereHas('latestSptVersion')
->orderByDesc('version');
}
/**
* The relationship between a mod and its last updated version.
*/
public function lastUpdatedVersion(): HasOne
{
return $this->hasOne(ModVersion::class)
->whereHas('latestSptVersion')
->orderByDesc('updated_at');
->orderByDesc('version_major')
->orderByDesc('version_minor')
->orderByDesc('version_patch')
->orderByDesc('version_pre_release')
->chaperone();
}
/**
@ -89,6 +110,11 @@ class Mod extends Model
*/
public function toSearchableArray(): array
{
$this->load([
'latestVersion',
'latestVersion.latestSptVersion',
]);
return [
'id' => $this->id,
'name' => $this->name,
@ -99,23 +125,11 @@ class Mod extends Model
'created_at' => strtotime($this->created_at),
'updated_at' => strtotime($this->updated_at),
'published_at' => strtotime($this->published_at),
'latestVersion' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->version_formatted,
'latestVersionColorClass' => $this->latestVersion()?->first()?->latestSptVersion()?->first()?->color_class,
'latestVersion' => $this->latestVersion->latestSptVersion->version_formatted,
'latestVersionColorClass' => $this->latestVersion->latestSptVersion->color_class,
];
}
/**
* The relationship to the latest mod version, dictated by the mod version number.
*/
public function latestVersion(): HasOne
{
return $this->hasOne(ModVersion::class)
->whereHas('sptVersions')
->orderByDesc('version')
->orderByDesc('updated_at')
->take(1);
}
/**
* Determine if the model instance should be searchable.
*/
@ -131,16 +145,27 @@ class Mod extends Model
return false;
}
// Fetch the latest version instance.
$latestVersion = $this->latestVersion()?->first();
// Eager load the latest mod version, and it's latest SPT version.
$this->load([
'latestVersion',
'latestVersion.latestSptVersion',
]);
// Ensure the mod has a latest version.
if (is_null($latestVersion)) {
if ($this->latestVersion()->doesntExist()) {
return false;
}
// Ensure the latest version has a latest SPT version.
if ($latestVersion->latestSptVersion()->doesntExist()) {
if ($this->latestVersion->latestSptVersion()->doesntExist()) {
return false;
}
// Ensure the latest SPT version is within the last three minor versions.
$activeSptVersions = Cache::remember('active-spt-versions', 60 * 60, function () {
return SptVersion::getVersionsForLastThreeMinors();
});
if (! in_array($this->latestVersion->latestSptVersion->version, $activeSptVersions->pluck('version')->toArray())) {
return false;
}
@ -148,6 +173,24 @@ class Mod extends Model
return true;
}
/**
* The relationship between a mod and its latest version.
*
* @return HasOne<ModVersion>
*/
public function latestVersion(): HasOne
{
return $this->versions()
->one()
->ofMany([
'version_major' => 'max',
'version_minor' => 'max',
'version_patch' => 'max',
'version_pre_release' => 'max',
])
->chaperone();
}
/**
* Build the URL to the mod's thumbnail.
*/
@ -197,11 +240,17 @@ class Mod extends Model
'contains_ai_content' => 'boolean',
'contains_ads' => 'boolean',
'disabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
'published_at' => 'datetime',
];
}
/**
* Mutate the slug attribute to always be lower case on get and slugified on set.
*
* @return Attribute<string, string>
*/
protected function slug(): Attribute
{

View File

@ -7,19 +7,14 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property int $mod_version_id
* @property int $dependency_mod_id
* @property string $constraint
* @property int|null $resolved_version_id
*/
class ModDependency extends Model
{
use HasFactory;
/**
* The relationship between the mod dependency and the mod version.
*
* @return BelongsTo<ModVersion, ModDependency>
*/
public function modVersion(): BelongsTo
{
@ -28,17 +23,33 @@ class ModDependency extends Model
/**
* The relationship between the mod dependency and the resolved dependency.
*
* @return HasMany<ModResolvedDependency>
*/
public function resolvedDependencies(): HasMany
{
return $this->hasMany(ModResolvedDependency::class, 'dependency_id');
return $this->hasMany(ModResolvedDependency::class, 'dependency_id')
->chaperone();
}
/**
* The relationship between the mod dependency and the dependent mod.
*
* @return BelongsTo<Mod, ModDependency>
*/
public function dependentMod(): BelongsTo
{
return $this->belongsTo(Mod::class, 'dependent_mod_id');
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}

View File

@ -9,6 +9,8 @@ class ModResolvedDependency extends Model
{
/**
* The relationship between the resolved dependency and the mod version.
*
* @return BelongsTo<ModVersion, ModResolvedDependency>
*/
public function modVersion(): BelongsTo
{
@ -17,6 +19,8 @@ class ModResolvedDependency extends Model
/**
* The relationship between the resolved dependency and the dependency.
*
* @return BelongsTo<ModDependency, ModResolvedDependency>
*/
public function dependency(): BelongsTo
{
@ -25,9 +29,22 @@ class ModResolvedDependency extends Model
/**
* The relationship between the resolved dependency and the resolved mod version.
*
* @return BelongsTo<ModVersion, ModResolvedDependency>
*/
public function resolvedModVersion(): BelongsTo
{
return $this->belongsTo(ModVersion::class, 'resolved_mod_version_id');
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}

View File

@ -2,23 +2,27 @@
namespace App\Models;
use App\Exceptions\InvalidVersionNumberException;
use App\Models\Scopes\DisabledScope;
use App\Models\Scopes\PublishedScope;
use App\Support\Version;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
* @property int $mod_id
* @property string $version
*/
class ModVersion extends Model
{
use HasFactory, SoftDeletes;
use HasFactory;
use SoftDeletes;
/**
* Update the parent mod's updated_at timestamp when the mod version is updated.
*/
protected $touches = ['mod'];
/**
* Post boot method to configure the model.
@ -26,11 +30,31 @@ class ModVersion extends Model
protected static function booted(): void
{
static::addGlobalScope(new DisabledScope);
static::addGlobalScope(new PublishedScope);
static::saving(function (ModVersion $model) {
// Extract the version sections from the version string.
try {
$version = new Version($model->version);
$model->version_major = $version->getMajor();
$model->version_minor = $version->getMinor();
$model->version_patch = $version->getPatch();
$model->version_pre_release = $version->getPreRelease();
} catch (InvalidVersionNumberException $e) {
$model->version_major = 0;
$model->version_minor = 0;
$model->version_patch = 0;
$model->version_pre_release = '';
}
});
}
/**
* The relationship between a mod version and mod.
*
* @return BelongsTo<Mod, ModVersion>
*/
public function mod(): BelongsTo
{
@ -39,14 +63,19 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its dependencies.
*
* @return HasMany<ModDependency>
*/
public function dependencies(): HasMany
{
return $this->hasMany(ModDependency::class);
return $this->hasMany(ModDependency::class)
->chaperone();
}
/**
* The relationship between a mod version and its resolved dependencies.
*
* @return BelongsToMany<ModVersion>
*/
public function resolvedDependencies(): BelongsToMany
{
@ -57,6 +86,8 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its each of it's resolved dependencies' latest versions.
*
* @return BelongsToMany<ModVersion>
*/
public function latestResolvedDependencies(): BelongsToMany
{
@ -71,22 +102,73 @@ class ModVersion extends Model
}
/**
* The relationship between a mod version and each of its SPT versions' latest version.
* Hint: Be sure to call `->first()` on this to get the actual instance.
* The relationship between a mod version and its latest SPT version.
*
* @return HasOneThrough<SptVersion>
*/
public function latestSptVersion(): BelongsToMany
public function latestSptVersion(): HasOneThrough
{
return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version')
->orderBy('version', 'desc')
return $this->hasOneThrough(SptVersion::class, ModVersionSptVersion::class, 'mod_version_id', 'id', 'id', 'spt_version_id')
->orderByDesc('spt_versions.version_major')
->orderByDesc('spt_versions.version_minor')
->orderByDesc('spt_versions.version_patch')
->orderByDesc('spt_versions.version_pre_release')
->limit(1);
}
/**
* The relationship between a mod version and its SPT versions.
*
* @return BelongsToMany<SptVersion>
*/
public function sptVersions(): BelongsToMany
{
return $this->belongsToMany(SptVersion::class, 'mod_version_spt_version')
->orderByDesc('version');
return $this->belongsToMany(SptVersion::class)
->using(ModVersionSptVersion::class)
->orderByDesc('version_major')
->orderByDesc('version_minor')
->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;
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'hub_id' => 'integer',
'version_major' => 'integer',
'version_minor' => 'integer',
'version_patch' => 'integer',
'downloads' => 'integer',
'disabled' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
'published_at' => 'datetime',
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
class ModVersionSptVersion extends Pivot
{
public $incrementing = true;
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class OAuthConnection extends Model
{
use HasFactory;
protected $table = 'oauth_connections';
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Exceptions\InvalidVersionNumberException;
use App\Support\Version;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@ -12,7 +13,8 @@ use Illuminate\Support\Facades\Cache;
class SptVersion extends Model
{
use HasFactory, SoftDeletes;
use HasFactory;
use SoftDeletes;
/**
* Get all versions for the last three minor versions.
@ -54,7 +56,7 @@ class SptVersion extends Model
->orderByDesc('version_minor')
->limit(3)
->get()
->map(function ($version) {
->map(function (SptVersion $version) {
return [
'major' => (int) $version->version_major,
'minor' => (int) $version->version_minor,
@ -63,35 +65,6 @@ class SptVersion extends Model
->toArray();
}
/**
* Called when the model is booted.
*/
protected static function booted(): void
{
// Callback that runs before saving the model.
static::saving(function ($model) {
// Extract the version sections from the version string.
if (! empty($model->version)) {
// Default values in case there's an exception.
$model->version_major = 0;
$model->version_minor = 0;
$model->version_patch = 0;
$model->version_pre_release = '';
try {
$versionSections = self::extractVersionSections($model->version);
} catch (InvalidVersionNumberException $e) {
return;
}
$model->version_major = $versionSections['major'];
$model->version_minor = $versionSections['minor'];
$model->version_patch = $versionSections['patch'];
$model->version_pre_release = $versionSections['pre_release'];
}
});
}
/**
* Extract the version sections from the version string.
*
@ -116,6 +89,29 @@ class SptVersion extends Model
];
}
/**
* Called when the model is booted.
*/
protected static function booted(): void
{
static::saving(function (SptVersion $model) {
// Extract the version sections from the version string.
try {
$version = new Version($model->version);
$model->version_major = $version->getMajor();
$model->version_minor = $version->getMinor();
$model->version_patch = $version->getPatch();
$model->version_pre_release = $version->getPreRelease();
} catch (InvalidVersionNumberException $e) {
$model->version_major = 0;
$model->version_minor = 0;
$model->version_patch = 0;
$model->version_pre_release = '';
}
});
}
/**
* Update the mod count for this SptVersion.
*/
@ -131,10 +127,13 @@ class SptVersion extends Model
/**
* The relationship between an SPT version and mod version.
*
* @return BelongsToMany<ModVersion>
*/
public function modVersions(): BelongsToMany
{
return $this->belongsToMany(ModVersion::class, 'mod_version_spt_version');
return $this->belongsToMany(ModVersion::class)
->using(ModVersionSptVersion::class);
}
/**
@ -173,4 +172,21 @@ class SptVersion extends Model
->first();
});
}
/**
* The attributes that should be cast to native types.
*/
protected function casts(): array
{
return [
'hub_id' => 'integer',
'version_major' => 'integer',
'version_minor' => 'integer',
'version_patch' => 'integer',
'mod_count' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'deleted_at' => 'datetime',
];
}
}

View File

@ -8,9 +8,11 @@ use App\Notifications\VerifyEmail;
use App\Traits\HasCoverPhoto;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
@ -42,14 +44,83 @@ class User extends Authenticatable implements MustVerifyEmail
'profile_photo_url',
];
/**
* Get the storage path for profile photos.
*/
public static function profilePhotoStoragePath(): string
{
return 'profile-photos';
}
/**
* The relationship between a user and their mods.
*
* @return BelongsToMany<Mod>
*/
public function mods(): BelongsToMany
{
return $this->belongsToMany(Mod::class);
}
/**
* The relationship between a user and users that follow them.
*
* @return BelongsToMany<User>
*/
public function followers(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_follows', 'following_id', 'follower_id')
->withTimestamps();
}
/**
* Follow another user.
*/
public function follow(User|int $user): void
{
$userId = $user instanceof User ? $user->id : $user;
if ($this->id === $userId) {
// Don't allow following yourself.
return;
}
$this->following()->syncWithoutDetaching([$userId]);
}
/**
* The relationship between a user and users they follow.
*
* @return BelongsToMany<User>
*/
public function following(): BelongsToMany
{
return $this->belongsToMany(User::class, 'user_follows', 'follower_id', 'following_id')
->withTimestamps();
}
/**
* Unfollow another user.
*/
public function unfollow(User|int $user): void
{
$userId = $user instanceof User ? $user->id : $user;
if ($this->isFollowing($userId)) {
$this->following()->detach($userId);
}
}
/**
* Check if the user is following another user.
*/
public function isFollowing(User|int $user): bool
{
$userId = $user instanceof User ? $user->id : $user;
return $this->following()->where('following_id', $userId)->exists();
}
/**
* The data that is searchable by Scout.
*/
@ -66,7 +137,9 @@ class User extends Authenticatable implements MustVerifyEmail
*/
public function shouldBeSearchable(): bool
{
return ! is_null($this->email_verified_at);
$this->load(['bans']);
return $this->isNotBanned();
}
/**
@ -132,6 +205,8 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The relationship between a user and their role.
*
* @return BelongsTo<UserRole, User>
*/
public function role(): BelongsTo
{
@ -146,6 +221,32 @@ class User extends Authenticatable implements MustVerifyEmail
return $filters->apply($builder);
}
/**
* The relationship between a user and their OAuth providers.
*/
public function oAuthConnections(): HasMany
{
return $this->hasMany(OAuthConnection::class);
}
/**
* Handle the about default value if empty. Thanks, MySQL!
*/
protected function about(): Attribute
{
return Attribute::make(
set: function ($value) {
// MySQL will not allow you to set a default value of an empty string for a (LONG)TEXT column. *le sigh*
// NULL is the default. If NULL is saved, we'll swap it out for an empty string.
if (is_null($value)) {
return '';
}
return $value;
},
);
}
/**
* Get the disk that profile photos should be stored on.
*/
@ -160,8 +261,12 @@ class User extends Authenticatable implements MustVerifyEmail
protected function casts(): array
{
return [
'id' => 'integer',
'hub_id' => 'integer',
'email_verified_at' => 'datetime',
'password' => 'hashed',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}

View File

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

View File

@ -18,6 +18,9 @@ class ResetPassword extends OriginalResetPassword implements ShouldQueue
parent::__construct($token);
}
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [];

View File

@ -13,6 +13,9 @@ class VerifyEmail extends OriginalVerifyEmail implements ShouldQueue
{
use Queueable;
/**
* Get the array representation of the notification.
*/
public function toArray(object $notifiable): array
{
return [];

View File

@ -20,6 +20,8 @@ class ModObserver
*/
public function saved(Mod $mod): void
{
$mod->load('versions.sptVersions');
foreach ($mod->versions as $modVersion) {
$this->dependencyVersionService->resolve($modVersion);
}
@ -44,6 +46,8 @@ class ModObserver
*/
public function deleted(Mod $mod): void
{
$mod->load('versions.sptVersions');
$this->updateRelatedSptVersions($mod);
}
}

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

@ -0,0 +1,25 @@
<?php
namespace App\Policies;
use App\Models\OAuthConnection;
use App\Models\User;
class OAuthConnectionPolicy
{
/**
* Determine whether the user can view the model.
*/
public function view(User $user, OAuthConnection $oauthConnection): bool
{
return $user->id === $oauthConnection->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, OAuthConnection $oauthConnection): bool
{
return $user->id === $oauthConnection->user_id && $user->password !== null;
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers;
use App\Livewire\Profile\UpdatePasswordForm;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
@ -11,11 +12,15 @@ use App\Observers\ModDependencyObserver;
use App\Observers\ModObserver;
use App\Observers\ModVersionObserver;
use App\Observers\SptVersionObserver;
use App\Services\LatestSptVersionService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
use SocialiteProviders\Discord\Provider;
use SocialiteProviders\Manager\SocialiteWasCalled;
class AppServiceProvider extends ServiceProvider
{
@ -24,9 +29,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
$this->app->singleton(LatestSptVersionService::class, function ($app) {
return new LatestSptVersionService;
});
//
}
/**
@ -37,18 +40,47 @@ class AppServiceProvider extends ServiceProvider
// Allow mass assignment for all models. Be careful!
Model::unguard();
// Register observers.
Mod::observe(ModObserver::class);
ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class);
SptVersion::observe(SptVersionObserver::class);
// Disable lazy loading in non-production environments.
Model::preventLazyLoading(! app()->isProduction());
// Register model observers.
$this->registerObservers();
// Register custom macros.
$this->registerNumberMacros();
$this->registerCarbonMacros();
// Register Livewire component overrides.
$this->registerLivewireOverrides();
// This gate determines who can access the Pulse dashboard.
Gate::define('viewPulse', function (User $user) {
return $user->isAdmin();
});
// Register a number macro to format download numbers.
// Register the Discord socialite provider.
Event::listen(function (SocialiteWasCalled $event) {
$event->extendSocialite('discord', Provider::class);
});
}
/**
* Register model observers.
*/
private function registerObservers(): void
{
Mod::observe(ModObserver::class);
ModVersion::observe(ModVersionObserver::class);
ModDependency::observe(ModDependencyObserver::class);
SptVersion::observe(SptVersionObserver::class);
}
/**
* Register custom number macros.
*/
private function registerNumberMacros(): void
{
// Format download numbers.
Number::macro('downloads', function (int|float $number) {
return Number::forHumans(
$number,
@ -58,4 +90,30 @@ class AppServiceProvider extends ServiceProvider
);
});
}
/**
* Register custom Carbon macros.
*/
private function registerCarbonMacros(): void
{
// Format dates dynamically based on the time passed.
Carbon::macro('dynamicFormat', function (Carbon $date) {
if ($date->diff(now())->m > 1) {
return $date->format('M jS, Y');
}
if ($date->diff(now())->d === 0) {
return $date->diffForHumans();
}
return $date->format('M jS, g:i A');
});
}
/**
* Register Livewire component overrides.
*/
private function registerLivewireOverrides(): void
{
Livewire::component('profile.update-password-form', UpdatePasswordForm::class);
}
}

74
app/Support/Version.php Normal file
View File

@ -0,0 +1,74 @@
<?php
namespace App\Support;
use App\Exceptions\InvalidVersionNumberException;
class Version
{
protected int $major = 0;
protected int $minor = 0;
protected int $patch = 0;
protected string $preRelease = '';
protected string $version;
/**
* Constructor.
*
* @throws InvalidVersionNumberException
*/
public function __construct(string $version)
{
$this->version = $version;
$this->parseVersion();
}
/**
* Parse the version string into its components.
*
* @throws InvalidVersionNumberException
*/
protected function parseVersion(): void
{
$matches = [];
// Regex to match semantic versioning, including pre-release identifiers
if (preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([\w.-]+))?$/', $this->version, $matches)) {
$this->major = (int) $matches[1];
$this->minor = (int) ($matches[2] ?? 0);
$this->patch = (int) ($matches[3] ?? 0);
$this->preRelease = $matches[4] ?? '';
} else {
throw new InvalidVersionNumberException('Invalid version number: '.$this->version);
}
}
public function getMajor(): int
{
return $this->major;
}
public function getMinor(): int
{
return $this->minor;
}
public function getPatch(): int
{
return $this->patch;
}
public function getPreRelease(): string
{
return $this->preRelease;
}
public function __toString(): string
{
return $this->version;
}
}

View File

@ -6,19 +6,32 @@ use Illuminate\Http\JsonResponse;
trait ApiResponses
{
/**
* Return a success JSON response.
*/
protected function success(string $message, ?array $data = []): JsonResponse
{
return $this->baseResponse(message: $message, data: $data, code: 200);
}
/**
* The base response.
*/
private function baseResponse(?string $message = '', ?array $data = [], ?int $code = 200): JsonResponse
{
return response()->json([
'message' => $message,
'data' => $data,
], $code);
$response = [];
$response['message'] = $message;
if ($data) {
$response['data'] = $data;
}
$response['status'] = $code;
return response()->json($response, $code);
}
/**
* Return an error JSON response.
*/
protected function error(string $message, int $code): JsonResponse
{
return $this->baseResponse(message: $message, code: $code);

View File

@ -11,7 +11,7 @@ trait HasCoverPhoto
/**
* Update the user's cover photo.
*/
public function updateCoverPhoto(UploadedFile $cover, $storagePath = 'cover-photos'): void
public function updateCoverPhoto(UploadedFile $cover, string $storagePath = 'cover-photos'): void
{
tap($this->cover_photo_path, function ($previous) use ($cover, $storagePath) {
$this->forceFill([
@ -51,15 +51,15 @@ trait HasCoverPhoto
}
/**
* Get the URL to the user's cover photo.
* Get the cover photo URL for the user.
*/
public function coverPhotoUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->cover_photo_path
return new Attribute(
get: fn (): string => $this->cover_photo_path
? Storage::disk($this->coverPhotoDisk())->url($this->cover_photo_path)
: $this->defaultCoverPhotoUrl();
});
: $this->defaultCoverPhotoUrl()
);
}
/**

View File

@ -0,0 +1,75 @@
<?php
namespace App\Traits\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Str;
trait FilterMethods
{
/**
* Filter using a whereIn clause.
*/
public function filterWhereIn(string $column, string $value): Builder
{
$ids = array_map('trim', explode(',', $value));
return $this->builder->whereIn($column, $ids);
}
/**
* Filter using a LIKE clause with a wildcard characters.
*/
public function filterByWildcardLike(string $column, string $value): Builder
{
$like = Str::replace('*', '%', $value);
return $this->builder->where($column, 'like', $like);
}
/**
* Filter by date range or specific date.
*/
public function filterByDate(string $column, string $value): Builder
{
$dates = array_map('trim', explode(',', $value));
if (count($dates) > 1) {
return $this->builder->whereBetween($column, $dates);
}
return $this->builder->whereDate($column, $dates[0]);
}
/**
* Filter by boolean value.
*/
public function filterByBoolean(string $column, string $value): Builder
{
$value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($value === null) {
return $this->builder; // The unmodified builder
}
return $this->builder->where($column, $value);
}
/**
* Apply the sort type to the query.
*/
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;
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\View\Components;
use App\Models\Mod;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\Component;
class HomepageMods extends Component
{
public function render(): View
{
return view('components.homepage-mods', [
'featured' => [
'title' => __('Featured Mods'),
'mods' => $this->fetchFeaturedMods(),
'link' => '/mods?featured=only',
],
'latest' => [
'title' => __('Newest Mods'),
'mods' => $this->fetchLatestMods(),
'link' => '/mods',
],
'updated' => [
'title' => __('Recently Updated Mods'),
'mods' => $this->fetchUpdatedMods(),
'link' => '/mods?order=updated',
],
]);
}
/**
* Fetches the featured mods homepage listing.
*/
private function fetchFeaturedMods(): Collection
{
return Cache::flexible('homepage-featured-mods', [5, 10], function () {
return Mod::whereFeatured(true)
->with([
'latestVersion',
'latestVersion.latestSptVersion',
'users:id,name',
'license:id,name,link',
])
->inRandomOrder()
->limit(6)
->get();
});
}
/**
* Fetches the latest mods homepage listing.
*/
private function fetchLatestMods(): Collection
{
return Cache::flexible('homepage-latest-mods', [5, 10], function () {
return Mod::orderByDesc('created_at')
->with([
'latestVersion',
'latestVersion.latestSptVersion',
'users:id,name',
'license:id,name,link',
])
->limit(6)
->get();
});
}
/**
* Fetches the recently updated mods homepage listing.
*/
private function fetchUpdatedMods(): Collection
{
return Cache::flexible('homepage-updated-mods', [5, 10], function () {
return Mod::orderByDesc('updated_at')
->with([
'latestUpdatedVersion',
'latestUpdatedVersion.latestSptVersion',
'users:id,name',
'license:id,name,link',
])
->limit(6)
->get();
});
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\View\Components;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\View\Component;
class ModList extends Component
{
public Collection $mods;
public string $versionScope;
public function __construct($mods, $versionScope)
{
$this->mods = $mods;
$this->versionScope = $versionScope;
}
public function render(): View
{
return view('components.mod-list', [
'mods' => $this->mods,
'versionScope' => $this->versionScope,
]);
}
}

View File

@ -1,104 +0,0 @@
<?php
namespace App\View\Components;
use App\Models\Mod;
use App\Models\ModVersion;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\View\Component;
class ModListSection extends Component
{
public Collection $modsFeatured;
public Collection $modsLatest;
public Collection $modsUpdated;
public function __construct()
{
$this->modsFeatured = $this->fetchFeaturedMods();
$this->modsLatest = $this->fetchLatestMods();
$this->modsUpdated = $this->fetchUpdatedMods();
}
private function fetchFeaturedMods(): Collection
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'downloads'])
->with([
'latestVersion',
'latestVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->whereFeatured(true)
->inRandomOrder()
->limit(6)
->get();
}
private function fetchLatestMods(): Collection
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'created_at', 'downloads'])
->with([
'latestVersion',
'latestVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->latest()
->limit(6)
->get();
}
private function fetchUpdatedMods(): Collection
{
return Mod::select(['id', 'name', 'slug', 'teaser', 'thumbnail', 'featured', 'downloads'])
->with([
'lastUpdatedVersion',
'lastUpdatedVersion.latestSptVersion:id,version,color_class',
'users:id,name',
'license:id,name,link',
])
->joinSub(
ModVersion::select('mod_id', DB::raw('MAX(updated_at) as latest_updated_at'))->groupBy('mod_id'),
'latest_versions',
'mods.id',
'=',
'latest_versions.mod_id'
)
->orderByDesc('latest_versions.latest_updated_at')
->limit(6)
->get();
}
public function render(): View
{
return view('components.mod-list-section', [
'sections' => $this->getSections(),
]);
}
public function getSections(): array
{
return [
[
'title' => __('Featured Mods'),
'mods' => $this->modsFeatured,
'versionScope' => 'latestVersion',
],
[
'title' => __('Newest Mods'),
'mods' => $this->modsLatest,
'versionScope' => 'latestVersion',
],
[
'title' => __('Recently Updated Mods'),
'mods' => $this->modsUpdated,
'versionScope' => 'lastUpdatedVersion',
],
];
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\View\Components;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class ModListStats extends Component
{
public function __construct(
public $mod,
public $modVersion
) {}
public function render(): View
{
return view('components.mod-list-stats');
}
}

View File

@ -24,4 +24,5 @@ return Application::configure(basePath: dirname(__DIR__))
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();
})
->create();

View File

@ -6,4 +6,5 @@ return [
App\Providers\HorizonServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
\SocialiteProviders\Manager\ServiceProvider::class,
];

View File

@ -19,24 +19,26 @@
"laravel/pulse": "^1.2",
"laravel/sanctum": "^4.0",
"laravel/scout": "^10.10",
"laravel/socialite": "^5.16",
"laravel/tinker": "^2.9",
"league/flysystem-aws-s3-v3": "^3.28",
"league/html-to-markdown": "^5.1",
"livewire/livewire": "^3.5",
"mchev/banhammer": "^2.3",
"meilisearch/meilisearch-php": "^1.8",
"socialiteproviders/discord": "^4.2",
"stevebauman/purify": "^6.2"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.13",
"fakerphp/faker": "^1.23",
"larastan/larastan": "^2.9",
"knuckleswtf/scribe": "^4.37",
"larastan/larastan": "^3.0",
"laravel/pint": "^1.16",
"laravel/sail": "^1.29",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.1",
"pestphp/pest": "^2.34",
"pestphp/pest-plugin-stressless": "^2.2",
"nunomaduro/collision": "^8.4",
"pestphp/pest": "^3.0",
"spatie/laravel-ignition": "^2.8"
},
"autoload": {

3130
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
<?php
use Illuminate\Support\Facades\Facade;
return [
/*
@ -123,4 +125,19 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
/*
|--------------------------------------------------------------------------
| Class Aliases
|--------------------------------------------------------------------------
|
| This array of class aliases will be registered when this application
| is started. However, feel free to register as many as you wish as
| the aliases are "lazy" loaded so they don't hinder performance.
|
*/
'aliases' => Facade::defaultAliases()->merge([
'Carbon' => \Carbon\Carbon::class,
])->toArray(),
];

View File

@ -114,7 +114,7 @@ return [
|
*/
'inject_assets' => true,
'inject_assets' => false,
/*
|---------------------------------------------------------------------------

269
config/scribe.php Normal file
View File

@ -0,0 +1,269 @@
<?php
use Knuckles\Scribe\Extracting\Strategies;
return [
// The HTML <title> for the generated documentation. If this is empty, Scribe will infer it from config('app.name').
'title' => 'The Forge API Documentation',
// A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec.
'description' => '',
// The base URL displayed in the docs. If this is empty, Scribe will use the value of config('app.url') at generation time.
// If you're using `laravel` type, you can set this to a dynamic string, like '{{ config("app.tenant_url") }}' to get a dynamic base URL.
'base_url' => null,
'routes' => [
[
// Routes that match these conditions will be included in the docs
'match' => [
// Match only routes whose paths match this pattern (use * as a wildcard to match any characters). Example: 'users/*'.
'prefixes' => ['api/*'],
// Match only routes whose domains match this pattern (use * as a wildcard to match any characters). Example: 'api.*'.
'domains' => ['*'],
// [Dingo router only] Match only routes registered under this version. Wildcards are NOT supported.
'versions' => ['v0'],
],
// Include these routes even if they did not match the rules above.
'include' => [
// 'users.index', 'POST /new', '/auth/*'
],
// Exclude these routes even if they matched the rules above.
'exclude' => [
// 'GET /health', 'admin.*'
],
],
],
// The type of documentation output to generate.
// - "static" will generate a static HTMl page in the /public/docs folder,
// - "laravel" will generate the documentation as a Blade view, so you can add routing and authentication.
// - "external_static" and "external_laravel" do the same as above, but generate a basic template,
// passing the OpenAPI spec as a URL, allowing you to easily use the docs with an external generator
'type' => 'static',
// See https://scribe.knuckles.wtf/laravel/reference/config#theme for supported options
'theme' => 'default',
'static' => [
// HTML documentation, assets and Postman collection will be generated to this folder.
// Source Markdown will still be in resources/docs.
'output_path' => 'public/docs',
],
'laravel' => [
// Whether to automatically create a docs endpoint for you to view your generated docs.
// If this is false, you can still set up routing manually.
'add_routes' => true,
// URL path to use for the docs endpoint (if `add_routes` is true).
// By default, `/docs` opens the HTML page, `/docs.postman` opens the Postman collection, and `/docs.openapi` the OpenAPI spec.
'docs_url' => '/docs',
// Directory within `public` in which to store CSS and JS assets.
// By default, assets are stored in `public/vendor/scribe`.
// If set, assets will be stored in `public/{{assets_directory}}`
'assets_directory' => null,
// Middleware to attach to the docs endpoint (if `add_routes` is true).
'middleware' => [],
],
'external' => [
'html_attributes' => [],
],
'try_it_out' => [
// Add a Try It Out button to your endpoints so consumers can test endpoints right from their browser.
// Don't forget to enable CORS headers for your endpoints.
'enabled' => true,
// The base URL for the API tester to use (for example, you can set this to your staging URL).
// Leave as null to use the current app URL when generating (config("app.url")).
'base_url' => null,
// [Laravel Sanctum] Fetch a CSRF token before each request, and add it as an X-XSRF-TOKEN header.
'use_csrf' => false,
// The URL to fetch the CSRF token from (if `use_csrf` is true).
'csrf_url' => '/sanctum/csrf-cookie',
],
// How is your API authenticated? This information will be used in the displayed docs, generated examples and response calls.
'auth' => [
// Set this to true if ANY endpoints in your API use authentication.
'enabled' => true,
// Set this to true if your API should be authenticated by default. If so, you must also set `enabled` (above) to true.
// You can then use @unauthenticated or @authenticated on individual endpoints to change their status from the default.
'default' => true,
// Where is the auth value meant to be sent in a request?
// Options: query, body, basic, bearer, header (for custom header)
'in' => 'bearer',
// The name of the auth parameter (eg token, key, apiKey) or header (eg Authorization, Api-Key).
'name' => 'Authorization',
// The value of the parameter to be used by Scribe to authenticate response calls.
// This will NOT be included in the generated documentation. If empty, Scribe will use a random value.
'use_value' => env('SCRIBE_AUTH_KEY'),
// Placeholder your users will see for the auth parameter in the example requests.
// Set this to null if you want Scribe to use a random value as placeholder instead.
'placeholder' => 'YOUR_API_KEY',
// Any extra authentication-related info for your users. Markdown and HTML are supported.
'extra_info' => 'You can generate your own API token by logging into The Forge, clicking your profile picture, and clicking <b>API Tokens</b>.',
],
// Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported.
'intro_text' => <<<'INTRO'
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO
,
// Example requests for each endpoint will be shown in each of these languages.
// Supported options are: bash, javascript, php, python
// To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests
'example_languages' => [
'javascript',
'php',
'python',
],
// Generate a Postman collection (v2.1.0) in addition to HTML docs.
// For 'static' docs, the collection will be generated to public/docs/collection.json.
// For 'laravel' docs, it will be generated to storage/app/scribe/collection.json.
// Setting `laravel.add_routes` to true (above) will also add a route for the collection.
'postman' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
// Generate an OpenAPI spec (v3.0.1) in addition to docs webpage.
// For 'static' docs, the collection will be generated to public/docs/openapi.yaml.
// For 'laravel' docs, it will be generated to storage/app/scribe/openapi.yaml.
// Setting `laravel.add_routes` to true (above) will also add a route for the spec.
'openapi' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
'groups' => [
// Endpoints which don't have a @group will be placed in this default group.
'default' => 'Endpoints',
// By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined.
// You can override this by listing the groups, subgroups and endpoints here in the order you want them.
// See https://scribe.knuckles.wtf/blog/laravel-v4#easier-sorting and https://scribe.knuckles.wtf/laravel/reference/config#order for details
'order' => [],
],
// Custom logo path. This will be used as the value of the src attribute for the <img> tag,
// so make sure it points to an accessible URL or path. Set to false to not use a logo.
// For example, if your logo is in public/img:
// - 'logo' => '../img/logo.png' // for `static` type (output folder is public/docs)
// - 'logo' => 'img/logo.png' // for `laravel` type
'logo' => false,
// Customize the "Last updated" value displayed in the docs by specifying tokens and formats.
// Examples:
// - {date:F j Y} => March 28, 2022
// - {git:short} => Short hash of the last Git commit
// Available tokens are `{date:<format>}` and `{git:<format>}`.
// The format you pass to `date` will be passed to PHP's `date()` function.
// The format you pass to `git` can be either "short" or "long".
'last_updated' => 'Last updated: {date:F j, Y}',
'examples' => [
// Set this to any number (eg. 1234) to generate the same example values for parameters on each run,
'faker_seed' => null,
// With API resources and transformers, Scribe tries to generate example models to use in your API responses.
// By default, Scribe will try the model's factory, and if that fails, try fetching the first from the database.
// You can reorder or remove strategies here.
'models_source' => ['factoryCreate', 'factoryMake', 'databaseFirst'],
],
// The strategies Scribe will use to extract information about your routes at each stage.
// If you create or install a custom strategy, add it here.
'strategies' => [
'metadata' => [
Strategies\Metadata\GetFromDocBlocks::class,
Strategies\Metadata\GetFromMetadataAttributes::class,
],
'urlParameters' => [
Strategies\UrlParameters\GetFromLaravelAPI::class,
Strategies\UrlParameters\GetFromUrlParamAttribute::class,
Strategies\UrlParameters\GetFromUrlParamTag::class,
],
'queryParameters' => [
Strategies\QueryParameters\GetFromFormRequest::class,
Strategies\QueryParameters\GetFromInlineValidator::class,
Strategies\QueryParameters\GetFromQueryParamAttribute::class,
Strategies\QueryParameters\GetFromQueryParamTag::class,
],
'headers' => [
Strategies\Headers\GetFromHeaderAttribute::class,
Strategies\Headers\GetFromHeaderTag::class,
[
'override',
[
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
],
],
'bodyParameters' => [
Strategies\BodyParameters\GetFromFormRequest::class,
Strategies\BodyParameters\GetFromInlineValidator::class,
Strategies\BodyParameters\GetFromBodyParamAttribute::class,
Strategies\BodyParameters\GetFromBodyParamTag::class,
],
'responses' => [
Strategies\Responses\UseResponseAttributes::class,
Strategies\Responses\UseTransformerTags::class,
Strategies\Responses\UseApiResourceTags::class,
Strategies\Responses\UseResponseTag::class,
Strategies\Responses\UseResponseFileTag::class,
[
Strategies\Responses\ResponseCalls::class,
[
'only' => ['GET *'],
// Disable debug mode when generating response calls to avoid error stack traces in responses
'config' => [
'app.debug' => false,
],
],
],
],
'responseFields' => [
Strategies\ResponseFields\GetFromResponseFieldAttribute::class,
Strategies\ResponseFields\GetFromResponseFieldTag::class,
],
],
// For response calls, API resource responses and transformer responses,
// Scribe will try to start database transactions, so no changes are persisted to your database.
// Tell Scribe which connections should be transacted here. If you only use one db connection, you can leave this as is.
'database_connections_to_transact' => [config('database.default')],
'fractal' => [
// If you are using a custom serializer with league/fractal, you can specify it here.
'serializer' => null,
],
'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class,
];

View File

@ -40,4 +40,12 @@ return [
'token' => env('GITEA_TOKEN', ''),
],
'discord' => [
'client_id' => env('DISCORD_CLIENT_ID'),
'client_secret' => env('DISCORD_CLIENT_SECRET'),
'redirect' => env('DISCORD_REDIRECT_URI'),
'allow_gif_avatars' => (bool) env('DISCORD_AVATAR_GIF', true),
'avatar_default_extension' => env('DISCORD_EXTENSION_DEFAULT', 'webp'),
],
];

View File

@ -6,6 +6,9 @@ use App\Models\License;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<License>
*/
class LicenseFactory extends Factory
{
protected $model = License::class;

View File

@ -8,6 +8,9 @@ use App\Models\ModVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<ModDependency>
*/
class ModDependencyFactory extends Factory
{
protected $model = ModDependency::class;

View File

@ -7,25 +7,23 @@ use App\Models\Mod;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Random\RandomException;
/**
* @extends Factory<Mod>
*/
class ModFactory extends Factory
{
protected $model = Mod::class;
/**
* @throws RandomException
*/
public function definition(): array
{
$name = fake()->catchPhrase();
$name = fake()->sentence(rand(3, 5));
return [
'name' => $name,
'slug' => Str::slug($name),
'teaser' => fake()->sentence(),
'description' => fake()->paragraphs(random_int(4, 20), true),
'description' => fake()->paragraphs(rand(4, 20), true),
'license_id' => License::factory(),
'source_code_link' => fake()->url(),
'featured' => fake()->boolean(),

View File

@ -8,6 +8,9 @@ use App\Models\SptVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<ModVersion>
*/
class ModVersionFactory extends Factory
{
protected $model = ModVersion::class;
@ -16,7 +19,7 @@ class ModVersionFactory extends Factory
{
return [
'mod_id' => Mod::factory(),
'version' => fake()->numerify('#.#.#'),
'version' => $this->faker->numerify('#.#.#'),
'description' => fake()->text(),
'link' => fake()->url(),

View File

@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\OAuthConnection;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
class OAuthConnectionFactory extends Factory
{
protected $model = OAuthConnection::class;
/**
* Define the model's default state.
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'provider_name' => $this->faker->randomElement(['discord', 'google', 'facebook']),
'provider_id' => (string) $this->faker->unique()->numberBetween(100000, 999999),
'token' => Str::random(40),
'refresh_token' => Str::random(40),
'created_at' => now(),
'updated_at' => now(),
];
}
}

View File

@ -6,6 +6,9 @@ use App\Models\SptVersion;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<SptVersion>
*/
class SptVersionFactory extends Factory
{
protected $model = SptVersion::class;

View File

@ -2,10 +2,15 @@
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Random\RandomException;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
@ -13,8 +18,12 @@ class UserFactory extends Factory
*/
protected static ?string $password;
protected $model = User::class;
/**
* Define the user's default state.
*
* @throws RandomException
*/
public function definition(): array
{
@ -23,6 +32,10 @@ class UserFactory extends Factory
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
// TODO: Does Faker have a markdown plugin?
'about' => fake()->paragraphs(random_int(1, 10), true),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
'remember_token' => Str::random(10),

View File

@ -6,6 +6,9 @@ use App\Models\UserRole;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;
/**
* @extends Factory<UserRole>
*/
class UserRoleFactory extends Factory
{
protected $model = UserRole::class;

View File

@ -14,14 +14,13 @@ return new class extends Migration
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->bigInteger('hub_id')
->nullable()
->default(null)
->unique();
$table->bigInteger('hub_id')->nullable()->default(null)->unique();
$table->unsignedBigInteger('discord_id')->nullable()->default(null)->unique();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('password')->nullable();
$table->longText('about')->nullable()->default(null);
$table->foreignIdFor(UserRole::class)
->nullable()
->default(null)

View File

@ -23,7 +23,7 @@ return new class extends PulseMigration
match ($this->driver()) {
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
'sqlite' => $table->string('key_hash'),
default => $table->string('key_hash'),
};
$table->mediumText('value');
@ -40,7 +40,7 @@ return new class extends PulseMigration
match ($this->driver()) {
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
'sqlite' => $table->string('key_hash'),
default => $table->string('key_hash'),
};
$table->bigInteger('value')->nullable();
@ -59,7 +59,7 @@ return new class extends PulseMigration
match ($this->driver()) {
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
'sqlite' => $table->string('key_hash'),
default => $table->string('key_hash'),
};
$table->string('aggregate');
$table->decimal('value', 20, 2);

View File

@ -15,18 +15,17 @@ return new class extends Migration
->default(null)
->unique();
$table->string('version');
$table->unsignedInteger('version_major');
$table->unsignedInteger('version_minor');
$table->unsignedInteger('version_patch');
$table->string('version_pre_release');
$table->unsignedInteger('version_major')->default(0);
$table->unsignedInteger('version_minor')->default(0);
$table->unsignedInteger('version_patch')->default(0);
$table->string('version_pre_release')->default('');
$table->unsignedInteger('mod_count')->default(0);
$table->string('link');
$table->string('color_class');
$table->softDeletes();
$table->timestamps();
$table->index(['version', 'deleted_at', 'id'], 'spt_versions_filtering_index');
$table->index(['version_major', 'version_minor', 'version_patch', 'version_pre_release', 'deleted_at'], 'spt_versions_lookup_index');
$table->index(['version', 'version_major', 'version_minor', 'version_patch', 'version_pre_release'], 'spt_versions_lookup_index');
});
}

View File

@ -20,6 +20,10 @@ return new class extends Migration
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('version');
$table->unsignedInteger('version_major')->default(0);
$table->unsignedInteger('version_minor')->default(0);
$table->unsignedInteger('version_patch')->default(0);
$table->string('version_pre_release')->default('');
$table->longText('description');
$table->string('link');
$table->string('spt_version_constraint');
@ -32,6 +36,7 @@ return new class extends Migration
$table->index(['version']);
$table->index(['mod_id', 'deleted_at', 'disabled', 'published_at'], 'mod_versions_filtering_index');
$table->index(['version_major', 'version_minor', 'version_patch', 'version_pre_release'], 'mod_versions_version_components_index');
$table->index(['id', 'deleted_at'], 'mod_versions_id_deleted_at_index');
});
}

View File

@ -12,7 +12,6 @@ return new class extends Migration
$table->id();
$table->foreignId('mod_version_id')->constrained('mod_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('spt_version_id')->constrained('spt_versions')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
$table->index(['mod_version_id', 'spt_version_id'], 'mod_version_spt_version_index');
$table->index(['spt_version_id', 'mod_version_id'], 'spt_version_mod_version_index');

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_follows', function (Blueprint $table) {
$table->id();
$table->foreignId('follower_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreignId('following_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_follows');
}
};

View File

@ -0,0 +1,42 @@
<?php
use App\Models\User;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_connections', function (Blueprint $table) {
$table->id();
$table->foreignIdFor(User::class)
->constrained('users')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->string('provider');
$table->string('provider_id');
$table->string('token')->default('');
$table->string('refresh_token')->default('');
$table->string('nickname')->default('');
$table->string('name')->default('');
$table->string('email')->default('');
$table->string('avatar')->default('');
$table->timestamps();
$table->unique(['provider', 'provider_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_providers');
}
};

View File

@ -6,10 +6,13 @@ use App\Models\License;
use App\Models\Mod;
use App\Models\ModDependency;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Models\User;
use App\Models\UserRole;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Artisan;
use Laravel\Prompts\Progress;
use function Laravel\Prompts\progress;
class DatabaseSeeder extends Seeder
{
@ -18,46 +21,114 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// Create a few SPT versions.
$spt_versions = SptVersion::factory(30)->create();
// How many of each entity to create.
$counts = [
'license' => 10,
'administrator' => 5,
'moderator' => 5,
'user' => 100,
'mod' => 200,
'modVersion' => 1500,
];
// Create some code licenses.
$licenses = License::factory(10)->create();
// Licenses
$licenses = License::factory($counts['license'])->create();
// Add 5 administrators.
$administrator = UserRole::factory()->administrator()->create();
User::factory()->for($administrator, 'role')->create([
// Administrator Users
$administratorRole = UserRole::factory()->administrator()->create();
$testAccount = User::factory()->for($administratorRole, 'role')->create([
'email' => 'test@example.com',
]);
User::factory(4)->for($administrator, 'role')->create();
User::factory($counts['administrator'] - 1)->for($administratorRole, 'role')->create();
// Add 10 moderators.
$moderator = UserRole::factory()->moderator()->create();
User::factory(5)->for($moderator, 'role')->create();
$this->command->outputComponents()->info("Test account created: {$testAccount->email}");
// Add 100 users.
$users = User::factory(100)->create();
// Moderator Users
$moderatorRole = UserRole::factory()->moderator()->create();
User::factory($counts['moderator'])->for($moderatorRole, 'role')->create();
// Add 300 mods, assigning them to the users we just created.
$allUsers = $users->merge([$administrator, $moderator]);
$mods = Mod::factory(300)->recycle([$licenses])->create();
foreach ($mods as $mod) {
$userIds = $allUsers->random(rand(1, 3))->pluck('id')->toArray();
$mod->users()->attach($userIds);
}
// Users
progress(
label: 'Adding Users...',
steps: $counts['user'],
callback: fn () => User::factory()->create()
);
// Add 3000 mod versions, assigning them to the mods we just created.
$modVersions = ModVersion::factory(3000)->recycle([$mods, $spt_versions])->create();
// All Users
$allUsers = User::all();
// Add ModDependencies to a subset of ModVersions.
foreach ($modVersions as $modVersion) {
$hasDependencies = rand(0, 100) < 30; // 30% chance to have dependencies
if ($hasDependencies) {
$dependencyMods = $mods->random(rand(1, 3)); // 1 to 3 dependencies
// User Follows
progress(
label: 'adding user follows ...',
steps: $allUsers,
callback: function ($user) use ($allUsers) {
$hasFollowers = rand(0, 100) < 70; // 70% chance to have followers
$isFollowing = rand(0, 100) < 70; // 70% chance to be following other users
if ($hasFollowers) {
$followers = $allUsers->random(rand(1, 10))->pluck('id')->toArray();
$user->followers()->attach($followers);
}
if ($isFollowing) {
$following = $allUsers->random(rand(1, 10))->pluck('id')->toArray();
$user->following()->attach($following);
}
});
// Mods
$mods = collect(progress(
label: 'Adding Mods...',
steps: $counts['mod'],
callback: fn () => Mod::factory()->recycle([$licenses])->create()
));
// Attach users to mods
progress(
label: 'Attaching users to mods...',
steps: $mods,
callback: function (Mod $mod, Progress $progress) use ($allUsers) {
$userIds = $allUsers->random(rand(1, 3))->pluck('id')->toArray();
$mod->users()->attach($userIds);
}
);
// Add mod versions, assigning them to the mods we just created.
$modVersions = collect(progress(
label: 'Adding Mod Versions...',
steps: $counts['modVersion'],
callback: fn () => ModVersion::factory()->recycle([$mods])->create()
));
// Add mod dependencies to *some* mod versions.
progress(
label: 'Adding Mod Dependencies...',
steps: $modVersions,
callback: function (ModVersion $modVersion, Progress $progress) use ($mods) {
// 70% chance to not have dependencies
if (rand(0, 9) >= 3) {
return;
}
// Choose 1-3 random mods to be dependencies.
$dependencyMods = $mods->random(rand(1, 3));
foreach ($dependencyMods as $dependencyMod) {
ModDependency::factory()->recycle([$modVersion, $dependencyMod])->create();
}
}
}
);
$this->command->outputComponents()->success('Initial seeding complete');
Artisan::call('app:search-sync');
Artisan::call('app:resolve-versions');
Artisan::call('app:count-mods');
Artisan::call('app:update-downloads');
$this->command->outputComponents()->warn('Jobs added to queue. Ensure Horizon is running!');
Artisan::call('cache:clear');
$this->command->outputComponents()->info('Cache cleared');
$this->command->outputComponents()->success('Database seeding complete');
}
}

451
package-lock.json generated
View File

@ -1,24 +1,24 @@
{
"name": "html",
"name": "forge",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@alpinejs/focus": "^3.14.1"
"@alpinejs/focus": "^3.14.3"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10",
"autoprefixer": "^10.4.16",
"axios": "^1.6.4",
"chokidar": "^3.6.0",
"chokidar": "^4.0.1",
"laravel-vite-plugin": "^1.0",
"postcss": "^8.4.32",
"postcss": "^8.4.49",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.6.5",
"tailwindcss": "^3.4.0",
"vite": "^5.0"
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^3.4.15",
"vite": "^5.4"
}
},
"node_modules/@alloc/quick-lru": {
@ -35,9 +35,9 @@
}
},
"node_modules/@alpinejs/focus": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/@alpinejs/focus/-/focus-3.14.1.tgz",
"integrity": "sha512-z4xdpK6X1LB2VitsWbL61tmABoOORuEhE5v2tnUX/be6/nAygXyeDxZ1x9s1u+bOEYlIOXXLmjdmTlhchUVWxw==",
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/@alpinejs/focus/-/focus-3.14.3.tgz",
"integrity": "sha512-ZBL6HziMXhQIuta3PQjpYaMb5Ro9VPqh0mkP+d1uefJnhliBMWUfQXOnobV/0zJUB9pDxzd78diDX3ywewoJ3g==",
"license": "MIT",
"dependencies": {
"focus-trap": "^6.9.4",
@ -556,9 +556,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.2.tgz",
"integrity": "sha512-fSuPrt0ZO8uXeS+xP3b+yYTCBUd05MoSp2N/MFOgjhhUhMmchXlpTQrTpI8T+YAwAQuK7MafsCOxW7VrPMrJcg==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.27.4.tgz",
"integrity": "sha512-2Y3JT6f5MrQkICUyRVCw4oa0sutfAsgaSsb0Lmmy1Wi2y7X5vT9Euqw4gOsCyy0YfKURBg35nhUKZS4mDcfULw==",
"cpu": [
"arm"
],
@ -570,9 +570,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.2.tgz",
"integrity": "sha512-xGU5ZQmPlsjQS6tzTTGwMsnKUtu0WVbl0hYpTPauvbRAnmIvpInhJtgjj3mcuJpEiuUw4v1s4BimkdfDWlh7gA==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.27.4.tgz",
"integrity": "sha512-wzKRQXISyi9UdCVRqEd0H4cMpzvHYt1f/C3CoIjES6cG++RHKhrBj2+29nPF0IB5kpy9MS71vs07fvrNGAl/iA==",
"cpu": [
"arm64"
],
@ -584,9 +584,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.2.tgz",
"integrity": "sha512-99AhQ3/ZMxU7jw34Sq8brzXqWH/bMnf7ZVhvLk9QU2cOepbQSVTns6qoErJmSiAvU3InRqC2RRZ5ovh1KN0d0Q==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.27.4.tgz",
"integrity": "sha512-PlNiRQapift4LNS8DPUHuDX/IdXiLjf8mc5vdEmUR0fF/pyy2qWwzdLjB+iZquGr8LuN4LnUoSEvKRwjSVYz3Q==",
"cpu": [
"arm64"
],
@ -598,9 +598,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.2.tgz",
"integrity": "sha512-ZbRaUvw2iN/y37x6dY50D8m2BnDbBjlnMPotDi/qITMJ4sIxNY33HArjikDyakhSv0+ybdUxhWxE6kTI4oX26w==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.27.4.tgz",
"integrity": "sha512-o9bH2dbdgBDJaXWJCDTNDYa171ACUdzpxSZt+u/AAeQ20Nk5x+IhA+zsGmrQtpkLiumRJEYef68gcpn2ooXhSQ==",
"cpu": [
"x64"
],
@ -611,10 +611,38 @@
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.27.4.tgz",
"integrity": "sha512-NBI2/i2hT9Q+HySSHTBh52da7isru4aAAo6qC3I7QFVsuhxi2gM8t/EI9EVcILiHLj1vfi+VGGPaLOUENn7pmw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.27.4.tgz",
"integrity": "sha512-wYcC5ycW2zvqtDYrE7deary2P2UFmSh85PUpAx+dwTCO9uw3sgzD6Gv9n5X4vLaQKsrfTSZZ7Z7uynQozPVvWA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.2.tgz",
"integrity": "sha512-ztRJJMiE8nnU1YFcdbd9BcH6bGWG1z+jP+IPW2oDUAPxPjo9dverIOyXz76m6IPA6udEL12reYeLojzW2cYL7w==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.27.4.tgz",
"integrity": "sha512-9OwUnK/xKw6DyRlgx8UizeqRFOfi9mf5TYCw1uolDaJSbUmBxP85DE6T4ouCMoN6pXw8ZoTeZCSEfSaYo+/s1w==",
"cpu": [
"arm"
],
@ -626,9 +654,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.2.tgz",
"integrity": "sha512-flOcGHDZajGKYpLV0JNc0VFH361M7rnV1ee+NTeC/BQQ1/0pllYcFmxpagltANYt8FYf9+kL6RSk80Ziwyhr7w==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.27.4.tgz",
"integrity": "sha512-Vgdo4fpuphS9V24WOV+KwkCVJ72u7idTgQaBoLRD0UxBAWTF9GWurJO9YD9yh00BzbkhpeXtm6na+MvJU7Z73A==",
"cpu": [
"arm"
],
@ -640,9 +668,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.2.tgz",
"integrity": "sha512-69CF19Kp3TdMopyteO/LJbWufOzqqXzkrv4L2sP8kfMaAQ6iwky7NoXTp7bD6/irKgknDKM0P9E/1l5XxVQAhw==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.27.4.tgz",
"integrity": "sha512-pleyNgyd1kkBkw2kOqlBx+0atfIIkkExOTiifoODo6qKDSpnc6WzUY5RhHdmTdIJXBdSnh6JknnYTtmQyobrVg==",
"cpu": [
"arm64"
],
@ -654,9 +682,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.2.tgz",
"integrity": "sha512-48pD/fJkTiHAZTnZwR0VzHrao70/4MlzJrq0ZsILjLW/Ab/1XlVUStYyGt7tdyIiVSlGZbnliqmult/QGA2O2w==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.27.4.tgz",
"integrity": "sha512-caluiUXvUuVyCHr5DxL8ohaaFFzPGmgmMvwmqAITMpV/Q+tPoaHZ/PWa3t8B2WyoRcIIuu1hkaW5KkeTDNSnMA==",
"cpu": [
"arm64"
],
@ -668,9 +696,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.2.tgz",
"integrity": "sha512-cZdyuInj0ofc7mAQpKcPR2a2iu4YM4FQfuUzCVA2u4HI95lCwzjoPtdWjdpDKyHxI0UO82bLDoOaLfpZ/wviyQ==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.27.4.tgz",
"integrity": "sha512-FScrpHrO60hARyHh7s1zHE97u0KlT/RECzCKAdmI+LEoC1eDh/RDji9JgFqyO+wPDb86Oa/sXkily1+oi4FzJQ==",
"cpu": [
"ppc64"
],
@ -682,9 +710,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.2.tgz",
"integrity": "sha512-RL56JMT6NwQ0lXIQmMIWr1SW28z4E4pOhRRNqwWZeXpRlykRIlEpSWdsgNWJbYBEWD84eocjSGDu/XxbYeCmwg==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.27.4.tgz",
"integrity": "sha512-qyyprhyGb7+RBfMPeww9FlHwKkCXdKHeGgSqmIXw9VSUtvyFZ6WZRtnxgbuz76FK7LyoN8t/eINRbPUcvXB5fw==",
"cpu": [
"riscv64"
],
@ -696,9 +724,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.2.tgz",
"integrity": "sha512-PMxkrWS9z38bCr3rWvDFVGD6sFeZJw4iQlhrup7ReGmfn7Oukrr/zweLhYX6v2/8J6Cep9IEA/SmjXjCmSbrMQ==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.27.4.tgz",
"integrity": "sha512-PFz+y2kb6tbh7m3A7nA9++eInGcDVZUACulf/KzDtovvdTizHpZaJty7Gp0lFwSQcrnebHOqxF1MaKZd7psVRg==",
"cpu": [
"s390x"
],
@ -710,9 +738,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.2.tgz",
"integrity": "sha512-B90tYAUoLhU22olrafY3JQCFLnT3NglazdwkHyxNDYF/zAxJt5fJUB/yBoWFoIQ7SQj+KLe3iL4BhOMa9fzgpw==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.27.4.tgz",
"integrity": "sha512-Ni8mMtfo+o/G7DVtweXXV/Ol2TFf63KYjTtoZ5f078AUgJTmaIJnj4JFU7TK/9SVWTaSJGxPi5zMDgK4w+Ez7Q==",
"cpu": [
"x64"
],
@ -724,9 +752,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.2.tgz",
"integrity": "sha512-7twFizNXudESmC9oneLGIUmoHiiLppz/Xs5uJQ4ShvE6234K0VB1/aJYU3f/4g7PhssLGKBVCC37uRkkOi8wjg==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.27.4.tgz",
"integrity": "sha512-5AeeAF1PB9TUzD+3cROzFTnAJAcVUGLuR8ng0E0WXGkYhp6RD6L+6szYVX+64Rs0r72019KHZS1ka1q+zU/wUw==",
"cpu": [
"x64"
],
@ -738,9 +766,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.2.tgz",
"integrity": "sha512-9rRero0E7qTeYf6+rFh3AErTNU1VCQg2mn7CQcI44vNUWM9Ze7MSRS/9RFuSsox+vstRt97+x3sOhEey024FRQ==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.27.4.tgz",
"integrity": "sha512-yOpVsA4K5qVwu2CaS3hHxluWIK5HQTjNV4tWjQXluMiiiu4pJj4BN98CvxohNCpcjMeTXk/ZMJBRbgRg8HBB6A==",
"cpu": [
"arm64"
],
@ -752,9 +780,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.2.tgz",
"integrity": "sha512-5rA4vjlqgrpbFVVHX3qkrCo/fZTj1q0Xxpg+Z7yIo3J2AilW7t2+n6Q8Jrx+4MrYpAnjttTYF8rr7bP46BPzRw==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.27.4.tgz",
"integrity": "sha512-KtwEJOaHAVJlxV92rNYiG9JQwQAdhBlrjNRp7P9L8Cb4Rer3in+0A+IPhJC9y68WAi9H0sX4AiG2NTsVlmqJeQ==",
"cpu": [
"ia32"
],
@ -766,9 +794,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.2.tgz",
"integrity": "sha512-6UUxd0+SKomjdzuAcp+HAmxw1FlGBnl1v2yEPSabtx4lBfdXHDVsW7+lQkgz9cNFJGY3AWR7+V8P5BqkD9L9nA==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.27.4.tgz",
"integrity": "sha512-3j4jx1TppORdTAoBJRd+/wJRGCPC0ETWkXOecJ6PPZLj6SptXkrXcNqdj0oclbKML6FkQltdz7bBA3rUSirZug==",
"cpu": [
"x64"
],
@ -780,9 +808,9 @@
]
},
"node_modules/@tailwindcss/forms": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.8.tgz",
"integrity": "sha512-DJs7B7NPD0JH7BVvdHWNviWmunlFhuEkz7FyFxE4japOWYMLl9b1D6+Z9mivJJPWr6AEbmlPqgiFRyLwFB1SgQ==",
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
"integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -809,16 +837,16 @@
}
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
@ -915,9 +943,9 @@
}
},
"node_modules/axios": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.6.tgz",
"integrity": "sha512-Ekur6XDwhnJ5RgOCaxFnXyqlPALI3rVeukZMwOdfghW7/wGz784BYKiQq+QD8NPcr91KRo30KfHOchyijwWw7g==",
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -970,9 +998,9 @@
}
},
"node_modules/browserslist": {
"version": "4.23.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz",
"integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==",
"version": "4.24.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
"integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
"dev": true,
"funding": [
{
@ -990,10 +1018,10 @@
],
"license": "MIT",
"dependencies": {
"caniuse-lite": "^1.0.30001646",
"electron-to-chromium": "^1.5.4",
"caniuse-lite": "^1.0.30001669",
"electron-to-chromium": "^1.5.41",
"node-releases": "^2.0.18",
"update-browserslist-db": "^1.1.0"
"update-browserslist-db": "^1.1.1"
},
"bin": {
"browserslist": "cli.js"
@ -1013,9 +1041,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001655",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001655.tgz",
"integrity": "sha512-jRGVy3iSGO5Uutn2owlb5gR6qsGngTw9ZTb4ali9f3glshcNmJ2noam4Mo9zia5P9Dk3jNNydy7vQjuE5dQmfg==",
"version": "1.0.30001684",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001684.tgz",
"integrity": "sha512-G1LRwLIQjBQoyq0ZJGqGIJUXzJ8irpbjHLpVRXDvBEScFJ9b17sgK6vlx0GAJFE21okD7zXl08rRRUfq6HdoEQ==",
"dev": true,
"funding": [
{
@ -1034,28 +1062,19 @@
"license": "CC-BY-4.0"
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 8.10.0"
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/color-convert": {
@ -1102,9 +1121,9 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1161,9 +1180,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.13",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.13.tgz",
"integrity": "sha512-lbBcvtIJ4J6sS4tb5TLp1b4LyfCdMkwStzXPyAgVgTRAsep4bvrAGaBOP7ZJtQMNJpSQ9SqG4brWOroNaQtm7Q==",
"version": "1.5.65",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.65.tgz",
"integrity": "sha512-PWVzBjghx7/wop6n22vS2MLU8tKGd4Q91aCEGhG/TYmW6PP5OcSXcdnxTe1NNt0T66N8D6jxh4kC8UsdzOGaIw==",
"dev": true,
"license": "ISC"
},
@ -1240,6 +1259,19 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-glob/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@ -1273,9 +1305,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"dev": true,
"funding": [
{
@ -1311,9 +1343,9 @@
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1386,16 +1418,16 @@
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
"is-glob": "^4.0.3"
},
"engines": {
"node": ">= 6"
"node": ">=10.13.0"
}
},
"node_modules/hasown": {
@ -1517,9 +1549,9 @@
}
},
"node_modules/laravel-vite-plugin": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.0.5.tgz",
"integrity": "sha512-Zv+to82YLBknDCZ6g3iwOv9wZ7f6EWStb9pjSm7MGe9Mfoy5ynT2ssZbGsMr1udU6rDg9HOoYEVGw5Qf+p9zbw==",
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.0.6.tgz",
"integrity": "sha512-B34OqmZc/rV1KvSjst8SsUm/LKHsuDusw8jiZCIhlnTHXbXnK89JUM9pTJuk6E/Vc/1DT2gX7qNfhipak1WS8w==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1743,9 +1775,9 @@
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz",
"integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
@ -1784,9 +1816,9 @@
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
@ -1824,9 +1856,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.41",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"dev": true,
"funding": [
{
@ -1845,8 +1877,8 @@
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@ -2017,9 +2049,9 @@
}
},
"node_modules/prettier-plugin-tailwindcss": {
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.6.tgz",
"integrity": "sha512-OPva5S7WAsPLEsOuOWXATi13QrCKACCiIonFgIR6V4lYv4QLp++UXVhZSzRbZxXGimkQtQT86CC6fQqTOybGng==",
"version": "0.6.9",
"resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.9.tgz",
"integrity": "sha512-r0i3uhaZAXYP0At5xGfJH876W3HHGHDp+LCRUJrs57PBeQ6mYHMwr25KH8NPX44F2yGTvdnH7OqCshlQx183Eg==",
"dev": true,
"license": "MIT",
"engines": {
@ -2134,16 +2166,17 @@
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz",
"integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
"node": ">= 14.16.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resolve": {
@ -2176,13 +2209,13 @@
}
},
"node_modules/rollup": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.2.tgz",
"integrity": "sha512-e3TapAgYf9xjdLvKQCkQTnbTKd4a6jwlpQSJJFokHGaX2IVjoEqkIIhiQfqsi0cdwlOD+tQGuOd5AJkc5RngBw==",
"version": "4.27.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.27.4.tgz",
"integrity": "sha512-RLKxqHEMjh/RGLsDxAEsaLO3mWgyoU6x9w6n1ikAzet4B3gI2/3yP6PWY2p9QzRTh6MfEIXB3MwsOY0Iv3vNrw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.5"
"@types/estree": "1.0.6"
},
"bin": {
"rollup": "dist/bin/rollup"
@ -2192,22 +2225,24 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.21.2",
"@rollup/rollup-android-arm64": "4.21.2",
"@rollup/rollup-darwin-arm64": "4.21.2",
"@rollup/rollup-darwin-x64": "4.21.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.21.2",
"@rollup/rollup-linux-arm-musleabihf": "4.21.2",
"@rollup/rollup-linux-arm64-gnu": "4.21.2",
"@rollup/rollup-linux-arm64-musl": "4.21.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.21.2",
"@rollup/rollup-linux-riscv64-gnu": "4.21.2",
"@rollup/rollup-linux-s390x-gnu": "4.21.2",
"@rollup/rollup-linux-x64-gnu": "4.21.2",
"@rollup/rollup-linux-x64-musl": "4.21.2",
"@rollup/rollup-win32-arm64-msvc": "4.21.2",
"@rollup/rollup-win32-ia32-msvc": "4.21.2",
"@rollup/rollup-win32-x64-msvc": "4.21.2",
"@rollup/rollup-android-arm-eabi": "4.27.4",
"@rollup/rollup-android-arm64": "4.27.4",
"@rollup/rollup-darwin-arm64": "4.27.4",
"@rollup/rollup-darwin-x64": "4.27.4",
"@rollup/rollup-freebsd-arm64": "4.27.4",
"@rollup/rollup-freebsd-x64": "4.27.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.27.4",
"@rollup/rollup-linux-arm-musleabihf": "4.27.4",
"@rollup/rollup-linux-arm64-gnu": "4.27.4",
"@rollup/rollup-linux-arm64-musl": "4.27.4",
"@rollup/rollup-linux-powerpc64le-gnu": "4.27.4",
"@rollup/rollup-linux-riscv64-gnu": "4.27.4",
"@rollup/rollup-linux-s390x-gnu": "4.27.4",
"@rollup/rollup-linux-x64-gnu": "4.27.4",
"@rollup/rollup-linux-x64-musl": "4.27.4",
"@rollup/rollup-win32-arm64-msvc": "4.27.4",
"@rollup/rollup-win32-ia32-msvc": "4.27.4",
"@rollup/rollup-win32-x64-msvc": "4.27.4",
"fsevents": "~2.3.2"
}
},
@ -2272,9 +2307,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@ -2428,34 +2463,34 @@
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"version": "3.4.15",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.15.tgz",
"integrity": "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
"chokidar": "^3.5.3",
"chokidar": "^3.6.0",
"didyoumean": "^1.2.2",
"dlv": "^1.1.3",
"fast-glob": "^3.3.0",
"fast-glob": "^3.3.2",
"glob-parent": "^6.0.2",
"is-glob": "^4.0.3",
"jiti": "^1.21.0",
"jiti": "^1.21.6",
"lilconfig": "^2.1.0",
"micromatch": "^4.0.5",
"micromatch": "^4.0.8",
"normalize-path": "^3.0.0",
"object-hash": "^3.0.0",
"picocolors": "^1.0.0",
"postcss": "^8.4.23",
"picocolors": "^1.1.1",
"postcss": "^8.4.47",
"postcss-import": "^15.1.0",
"postcss-js": "^4.0.1",
"postcss-load-config": "^4.0.1",
"postcss-nested": "^6.0.1",
"postcss-selector-parser": "^6.0.11",
"resolve": "^1.22.2",
"sucrase": "^3.32.0"
"postcss-load-config": "^4.0.2",
"postcss-nested": "^6.2.0",
"postcss-selector-parser": "^6.1.2",
"resolve": "^1.22.8",
"sucrase": "^3.35.0"
},
"bin": {
"tailwind": "lib/cli.js",
@ -2465,17 +2500,42 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"node_modules/tailwindcss/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
"is-glob": "^4.0.1"
},
"engines": {
"node": ">=10.13.0"
"node": ">= 6"
}
},
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
@ -2492,6 +2552,19 @@
"node": ">=4"
}
},
"node_modules/tailwindcss/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@ -2536,9 +2609,9 @@
"license": "Apache-2.0"
},
"node_modules/update-browserslist-db": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz",
"integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
"integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
"dev": true,
"funding": [
{
@ -2556,8 +2629,8 @@
],
"license": "MIT",
"dependencies": {
"escalade": "^3.1.2",
"picocolors": "^1.0.1"
"escalade": "^3.2.0",
"picocolors": "^1.1.0"
},
"bin": {
"update-browserslist-db": "cli.js"
@ -2574,14 +2647,14 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz",
"integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==",
"version": "5.4.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz",
"integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.41",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
@ -2759,9 +2832,9 @@
}
},
"node_modules/yaml": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
"integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
"dev": true,
"license": "ISC",
"bin": {

Some files were not shown because too many files have changed in this diff Show More