Merge remote-tracking branch 'upstream/develop' into impl/mod-card-moderation-options

This commit is contained in:
IsWaffle 2025-02-05 15:19:02 -05:00
commit c930a4d14f
214 changed files with 3853 additions and 3670 deletions

10
.env.ci
View File

@ -9,11 +9,11 @@ LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=testing
DB_USERNAME=user
DB_PASSWORD=password
DB_HOST=127.0.0.1
DB_PORT=33306
DB_DATABASE=test
DB_USERNAME=root
DB_PASSWORD=
SCOUT_DRIVER=collection
FILESYSTEM_DISK=local

View File

@ -87,8 +87,8 @@ DB_HUB_PASSWORD=
DB_HUB_CHARSET=utf8mb4
DB_HUB_COLLATION=utf8mb4_0900_ai_ci
GITEA_DOMAIN=
GITEA_TOKEN=
# GitHub Token for fetching release version numbers.
GITHUB_TOKEN=
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=

View File

@ -43,6 +43,9 @@ MAIL_MAILER=log
MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com"
MAIL_FROM_NAME="${APP_NAME}"
# GitHub Token for fetching release version numbers.
GITHUB_TOKEN=
# API key for Scribe documentation.
SCRIBE_AUTH_KEY=

8
.github/CODE_OF_CONDUCT.md vendored Normal file
View File

@ -0,0 +1,8 @@
# Code of Conduct
The Forge development code of conduct is derived from the Ruby code of conduct. Any violations of the code of conduct may be reported to Refringe at me@refringe.com.
- Participants will be tolerant of opposing views.
- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
- When interpreting the words and actions of others, participants should always assume good intentions.
- Behavior that can be reasonably considered harassment will not be tolerated.

38
.github/CONTRIBUTING.md vendored Normal file
View File

@ -0,0 +1,38 @@
# Contributing
## Development Discussion
*__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.
## Pull Request Guidelines
- **Keep Them Small**
If you're fixing a bug, try to keep the changes to the bug fix only. If you're adding a feature, try to keep the changes to the feature only. This will make it easier to review and merge your changes.
- **Perform a Self-Review**
Before submitting your changes, review your own code. This will help you catch any mistakes you may have made.
- **Remove Noise**
Remove any unnecessary changes to white space, code style formatting, or some text change that has no impact related to the intention of the PR.
- **Create a Meaningful Title**
When creating a PR, make sure the title is meaningful and describes the changes you've made.
- **Write Detailed Commit Messages**
Bring out your table manners, speak the Queen's English and be on your best behaviour.
## Style Guide
Forge follows the PSR-2 coding standard and the PSR-4 autoloading standard. We use an automated Laravel Pint action to enforce the coding standard, though it's suggested to run your code changes through Pint before contributing. This can be done by configuring your IDE to format with Pint on save, or manually by running the following command:
```
./vendor/bin/sail pint
```
### Tests
We have a number of tests that are run automatically when you submit a pull request. You can run these tests locally by running `php artisan test`. If you're adding a new feature or fixing a bug, please add tests to cover your changes so that we can ensure they don't break in the future. We use the [Pest PHP testing framework](https://pestphp.com).

3
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,3 @@
github: [ refringe ]
patreon: sptarkov
ko_fi: refringe

35
.github/README.md vendored
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://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.
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.
### Available Services:
@ -77,36 +77,3 @@ sail npm run dev
### More Information
For more information on Laravel development, please refer to the [official documentation](https://laravel.com/docs/11.x/).
## Development Discussion
*__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.
## Coding Style
Forge follows the PSR-2 coding standard and the PSR-4 autoloading standard. We use an automated Laravel Pint action to enforce the coding standard, though it's suggested to run your code changes through Pint before contributing. This can be done by configuring your IDE to format with Pint on save, or manually by running the following command:
```
./vendor/bin/sail pint
```
## Security Vulnerabilities
If you discover a security vulnerability within Forge, please email Refringe at me@refringe.com. All security vulnerabilities will be promptly addressed.
## Code of Conduct
The Forge development code of conduct is derived from the Ruby code of conduct. Any violations of the code of conduct may be reported to Refringe at me@refringe.com.
- Participants will be tolerant of opposing views.
- Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
- When interpreting the words and actions of others, participants should always assume good intentions.
- Behavior that can be reasonably considered harassment will not be tolerated.

3
.github/SECURITY.md vendored Normal file
View File

@ -0,0 +1,3 @@
# Security Policy
If you discover a security vulnerability within Forge, please email Refringe at me@refringe.com. All security vulnerabilities will be promptly addressed.

86
.github/action/setup/action.yml vendored Normal file
View File

@ -0,0 +1,86 @@
name: Setup
description: Setup and Cache PHP, Composer, and NPM.
inputs:
php-version:
description: PHP version(s) to use.
required: true
php-extensions:
description: PHP extensions to install.
required: false
default: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, gd, redis, igbinary, msgpack, memcached, gmp, :php-psr
redis-options:
description: Redis options to compile with.
required: false
default: --enable-redis --enable-redis-igbinary --enable-redis-msgpack --enable-redis-lzf --with-liblzf --enable-redis-zstd --with-libzstd --enable-redis-lz4 --with-liblz4
redis-libs:
description: Redis libraries to install.
required: false
default: liblz4-dev, liblzf-dev, libzstd-dev
runs:
using: composite
steps:
- name: Get PHP Extension Cache Hash
id: php-ext-cache-hash
env:
PHP_EXTENSIONS: ${{ inputs.php-extensions }}
REDIS_OPTIONS: ${{ inputs.redis-options }}
REDIS_LIBS: ${{ inputs.redis-libs }}
run: |
concat_values="${PHP_EXTENSIONS}${REDIS_OPTIONS}${REDIS_LIBS}"
echo "hash=$(echo $concat_values | md5sum | awk '{print $1}')" >> $GITHUB_OUTPUT
shell: bash
- name: Setup Cache Environment
id: php-ext-cache
uses: shivammathur/cache-extensions@v1
if: inputs.php-extensions != '' && inputs.redis-options != '' && inputs.redis-libs != ''
with:
php-version: ${{ inputs.php-version }}
extensions: ${{ inputs.php-extensions }}
key: ${{ runner.os }}-php-ext-${{ steps.php-ext-cache-hash.outputs.hash }}
- name: Cache PHP Extensions
uses: actions/cache@v4
if: inputs.php-extensions != '' && inputs.redis-options != '' && inputs.redis-libs != ''
with:
path: ${{ steps.php-ext-cache.outputs.dir }}
key: ${{ steps.php-ext-cache.outputs.key }}
restore-keys: ${{ steps.php-ext-cache.outputs.key }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ inputs.php-version }}
extensions: ${{ inputs.php-extensions }}
ini-values: error_reporting=E_ALL
tools: composer:v2
coverage: none
env:
REDIS_CONFIGURE_OPTS: ${{ inputs.redis-options }}
REDIS_LIBS: ${{ inputs.redis-libs }}
- name: Get Composer Cache Directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
shell: bash
- 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: Get NPM Cache Directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
shell: bash
- name: Cache NPM Dependencies
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-node-

View File

@ -4,37 +4,45 @@ updates:
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "America/Toronto"
open-pull-requests-limit: 5
target-branch: "develop"
versioning-strategy: increase-if-necessary
labels:
- "dependencies"
assignees:
reviewers:
- "Refringe"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "America/Toronto"
open-pull-requests-limit: 5
target-branch: "develop"
labels:
- "dependencies"
assignees:
reviewers:
- "Refringe"
# npm modules (JavaScript)
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
time: "15:00" # 10am EST
open-pull-requests-limit: 10
interval: "weekly"
day: "monday"
time: "10:00"
timezone: "America/Toronto"
open-pull-requests-limit: 5
target-branch: "develop"
versioning-strategy: increase-if-necessary
labels:
- "dependencies"
assignees:
reviewers:
- "Refringe"

20
.github/workflows/analyze.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Analyze
on:
push:
branches:
- main
- develop
jobs:
build:
name: Build and analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: SonarSource/sonarqube-scan-action@v4
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

21
.github/workflows/larastan.yaml vendored Normal file
View File

@ -0,0 +1,21 @@
name: Larastan
on: [ push, pull_request ]
jobs:
larastan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/action/setup
with:
php-version: 8.3
- name: Install Composer Dependencies
run: composer install -q --no-interaction --no-progress --prefer-dist --optimize-autoloader
- name: Execute Code Static Analysis with Larastan
run: ./vendor/bin/phpstan analyse -c ./phpstan.neon --no-progress --error-format=github

33
.github/workflows/pint.yaml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Pint
on:
push:
branches-ignore:
- main
jobs:
pint:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/action/setup
with:
php-version: 8.3
- name: Install Composer Dependencies
run: composer install -q --no-interaction --no-progress --prefer-dist --optimize-autoloader
- 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]
commit_user_name: Pint Bot
skip_fetch: true
file_pattern: '*.php'

View File

@ -1,93 +0,0 @@
name: Quality
on: [ push, pull_request ]
jobs:
security-checker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: The PHP Security Checker
uses: symfonycorp/security-checker-action@v5
larastan:
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-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]
commit_user_name: Pint Bot
skip_fetch: true
file_pattern: '*.php'

38
.github/workflows/rector.yaml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Rector
on: [ push, pull_request ]
jobs:
rector:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup
uses: ./.github/action/setup
with:
php-version: 8.3
- name: Install Composer Dependencies
run: composer install -q --no-interaction --no-progress --prefer-dist --optimize-autoloader
- name: Rector Cache
uses: actions/cache@v4
with:
path: .rector/cache
key: ${{ runner.os }}-rector-${{ github.run_id }}
restore-keys: ${{ runner.os }}-rector-
- run: mkdir -p .rector/cache
- name: Execute Rector Analysis
run: php vendor/bin/rector process --dry-run --config=rector.php
# Disable automated changes until we have a better understanding of the impact
# - uses: stefanzweifel/git-auto-commit-action@v5
# with:
# commit_message: Rector Fixes [no ci]
# commit_user_name: Rector Bot
# skip_fetch: true
# file_pattern: '*.php'

13
.github/workflows/security.yaml vendored Normal file
View File

@ -0,0 +1,13 @@
name: Security
on: [ push, pull_request ]
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: The PHP Security Checker
uses: symfonycorp/security-checker-action@v5

View File

@ -3,55 +3,31 @@ name: Tests
on: [ push, pull_request ]
jobs:
laravel-tests:
tests:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.3
ports:
- 33306:3306
env:
MYSQL_DATABASE: testing
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysql -u user -D testing -ppassword -h mysql -e ''" --health-interval=10s --health-timeout=5s --health-retries=3
MYSQL_DATABASE: test
MYSQL_ALLOW_EMPTY_PASSWORD: yes
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
- name: Setup
uses: ./.github/action/setup
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-
php-version: 8.3
- name: Install Composer Dependencies
run: composer install --no-progress --prefer-dist --optimize-autoloader
run: composer install -q --no-interaction --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
- name: Install NPM dependencies
run: npm ci
- name: Build Front-end Assets

1
.gitignore vendored
View File

@ -29,3 +29,4 @@ Homestead.yaml
npm-debug.log
yarn-error.log
.scribe
.rector

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
@ -26,7 +28,7 @@ class CreateNewUser implements CreatesNewUsers
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();
return User::create([
return User::query()->create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
@ -8,6 +10,8 @@ trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, Password|string>
*/
protected function passwordRules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
@ -13,6 +15,8 @@ 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

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
@ -13,6 +15,8 @@ class UpdateUserPassword implements UpdatesUserPasswords
/**
* Validate and update the user's password.
*
* @param array<string, mixed> $input
*/
public function update(User $user, array $input): void
{

View File

@ -1,9 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
@ -12,6 +13,8 @@ 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
{
@ -30,8 +33,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateCoverPhoto($input['cover']);
}
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
if ($input['email'] !== $user->email) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\User;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\Import\ImportHubDataJob;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\ResolveDependenciesJob;

View File

@ -1,7 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Mod;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
@ -15,8 +19,8 @@ class SearchSyncCommand extends Command
{
Artisan::call('scout:delete-all-indexes');
Artisan::call('scout:sync-index-settings');
Artisan::call('scout:import', ['model' => '\App\Models\Mod']);
Artisan::call('scout:import', ['model' => '\App\Models\User']);
Artisan::call('scout:import', ['model' => Mod::class]);
Artisan::call('scout:import', ['model' => User::class]);
$this->info('The search synchronisation jobs have been added to the queue');
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\SptVersionModCountsJob;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Jobs\UpdateModDownloadsJob;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;

View File

@ -1,14 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\Pages\CreateUser;
use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Override;
class UserResource extends Resource
{
@ -16,46 +25,48 @@ class UserResource extends Resource
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
#[Override]
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('email')
TextInput::make('email')
->email()
->required()
->maxLength(255),
Forms\Components\DateTimePicker::make('email_verified_at'),
Forms\Components\TextInput::make('password')
DateTimePicker::make('email_verified_at'),
TextInput::make('password')
->password()
->required()
->maxLength(255),
Forms\Components\TextInput::make('user_role_id')
TextInput::make('user_role_id')
->numeric(),
Forms\Components\TextInput::make('profile_photo_path')
TextInput::make('profile_photo_path')
->maxLength(2048),
Forms\Components\DateTimePicker::make('created_at'),
Forms\Components\DateTimePicker::make('updated_at'),
DateTimePicker::make('created_at'),
DateTimePicker::make('updated_at'),
]);
}
#[Override]
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('email')
TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('role.name')
TextColumn::make('role.name')
->sortable(),
Tables\Columns\TextColumn::make('email_verified_at')
TextColumn::make('email_verified_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('created_at')
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
@ -64,15 +75,16 @@ class UserResource extends Resource
//
])
->actions([
Tables\Actions\EditAction::make(),
EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
#[Override]
public static function getRelations(): array
{
return [
@ -80,12 +92,13 @@ class UserResource extends Resource
];
}
#[Override]
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
'index' => ListUsers::route('/'),
'create' => CreateUser::route('/create'),
'edit' => EditUser::route('/{record}/edit'),
];
}
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;

View File

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord
@ -13,7 +15,7 @@ class EditUser extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
DeleteAction::make(),
];
}
}

View File

@ -1,9 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
@ -13,7 +15,7 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
CreateAction::make(),
];
}
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
@ -34,16 +36,16 @@ class AuthController extends Controller
#[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
public function login(LoginUserRequest $loginUserRequest): JsonResponse
{
$request->validated($request->all());
$loginUserRequest->validated($loginUserRequest->all());
if (! Auth::attempt($request->only('email', 'password'))) {
if (! Auth::attempt($loginUserRequest->only('email', 'password'))) {
return $this->error(__('invalid credentials'), 401);
}
$user = User::firstWhere('email', $request->email);
$tokenName = $request->token_name ?? __('Dynamic API Token');
$user = User::query()->firstWhere('email', $loginUserRequest->email);
$tokenName = $loginUserRequest->token_name ?? __('Dynamic API Token');
return $this->success(__('authenticated'), [
// Only allowing the 'read' scope to be dynamically created. Can revisit later when writes are possible.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V0;
use App\Http\Controllers\Controller;
@ -12,12 +14,14 @@ class ApiController extends Controller
/**
* Determine if the given relationship should be included in the request. If more than one relationship is provided,
* only one needs to be present in the request for this method to return true.
*
* @param string|array<int, string> $relationships
*/
public static function shouldInclude(string|array $relationships): bool
{
try {
$param = request()->get('include');
} catch (NotFoundExceptionInterface|ContainerExceptionInterface $e) {
} catch (NotFoundExceptionInterface|ContainerExceptionInterface) {
return false;
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V0;
use App\Http\Filters\V1\ModFilter;
@ -33,9 +35,9 @@ class ModController extends ApiController
#[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
public function index(ModFilter $modFilter): AnonymousResourceCollection
{
return ModResource::collection(Mod::filter($filters)->paginate());
return ModResource::collection(Mod::filter($modFilter)->paginate());
}
/**

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V0;
use App\Http\Filters\V1\UserFilter;
@ -24,9 +26,9 @@ class UsersController extends ApiController
#[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
public function index(UserFilter $userFilter): AnonymousResourceCollection
{
return UserResource::collection(User::filter($filters)->paginate());
return UserResource::collection(User::filter($userFilter)->paginate());
}
/**

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
abstract class Controller

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Http\Requests\ModRequest;
@ -19,11 +21,11 @@ class ModController extends Controller
return view('mod.index');
}
public function store(ModRequest $request): ModResource
public function store(ModRequest $modRequest): ModResource
{
$this->authorize('create', Mod::class);
return new ModResource(Mod::create($request->validated()));
return new ModResource(Mod::query()->create($modRequest->validated()));
}
public function show(int $modId, string $slug): View
@ -37,20 +39,18 @@ class ModController extends Controller
'users',
])->findOrFail($modId);
if ($mod->slug !== $slug) {
abort(404);
}
abort_if($mod->slug !== $slug, 404);
$this->authorize('view', $mod);
return view('mod.show', compact(['mod']));
return view('mod.show', ['mod' => $mod]);
}
public function update(ModRequest $request, Mod $mod): ModResource
public function update(ModRequest $modRequest, Mod $mod): ModResource
{
$this->authorize('update', $mod);
$mod->update($request->validated());
$mod->update($modRequest->validated());
return new ModResource($mod);
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\ModVersion;
@ -18,17 +20,13 @@ class ModVersionController extends Controller
->whereVersion($version)
->firstOrFail();
if ($modVersion->mod->slug !== $slug) {
abort(404);
}
abort_if($modVersion->mod->slug !== $slug, 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);
}
abort_if(RateLimiter::tooManyAttempts($rateKey, maxAttempts: 5), 429);
// Increment downloads counts in the background.
defer(fn () => $modVersion->incrementDownloads());

View File

@ -1,9 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\OAuthConnection;
use App\Models\User;
use Exception;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
@ -18,6 +21,8 @@ class SocialiteController extends Controller
{
/**
* The providers that are supported.
*
* @var array<int, string>
*/
protected array $providers = ['discord'];
@ -50,7 +55,7 @@ class SocialiteController extends Controller
try {
$providerUser = Socialite::driver($provider)->user();
} catch (\Exception $e) {
} catch (Exception) {
return redirect()->route('login')->withErrors('Unable to login using '.$provider.'. Please try again.');
}
@ -67,7 +72,7 @@ class SocialiteController extends Controller
->whereProviderId($providerUser->getId())
->first();
if ($oauthConnection) {
if ($oauthConnection !== null) {
$oauthConnection->update([
'token' => $providerUser->token ?? '',
'refresh_token' => $providerUser->refreshToken ?? '',
@ -86,6 +91,7 @@ class SocialiteController extends Controller
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
@ -94,12 +100,12 @@ class SocialiteController extends Controller
return DB::transaction(function () use ($providerUser, $provider, $username) {
$user = User::firstOrCreate(['email' => $providerUser->getEmail()], [
$user = User::query()->firstOrCreate(['email' => $providerUser->getEmail()], [
'name' => $username,
'password' => null,
]);
$connection = $user->oAuthConnections()->create([
$oAuthConnection = $user->oAuthConnections()->create([
'provider' => $provider,
'provider_id' => $providerUser->getId(),
'token' => $providerUser->token ?? '',
@ -110,7 +116,7 @@ class SocialiteController extends Controller
'avatar' => $providerUser->getAvatar() ?? '',
]);
$this->updateAvatar($user, $connection->avatar);
$this->updateAvatar($user, $oAuthConnection->avatar);
return $user;
});
@ -125,10 +131,11 @@ class SocialiteController extends Controller
};
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_URL, $avatarUrl);
$image = curl_exec($curl);
curl_close($curl);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\User;
@ -27,14 +29,10 @@ class UserController extends Controller
->paginate(10)
->fragment('mods');
if ($user->slug() !== $username) {
abort(404);
}
abort_if($user->slug() !== $username, 404);
if ($request->user()?->cannot('view', $user)) {
abort(403);
}
abort_if($request->user()?->cannot('view', $user), 403);
return view('user.show', compact('user', 'mods'));
return view('user.show', ['user' => $user, 'mods' => $mods]);
}
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Filters;
use App\Models\Mod;
@ -10,31 +12,35 @@ class ModFilter
{
/**
* The query builder instance for the mod model.
*
* @var Builder<Mod>
*/
protected Builder $builder;
/**
* The filters to apply.
*/
protected array $filters;
/**
* Create a new ModFilter instance.
*/
public function __construct(array $filters)
{
$this->filters = $filters;
public function __construct(
/**
* The filters to apply to the query.
*
* @var array<string, mixed>
*/
protected array $filters
) {
$this->builder = $this->baseQuery();
}
/**
* The base query for the mod listing.
*
* @return Builder<Mod>
*/
private function baseQuery(): Builder
{
return Mod::query()
->select('mods.*')
->whereExists(function ($query) {
->whereExists(function ($query): void {
$query->select(DB::raw(1))
->from('mod_versions')
->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id')
@ -51,14 +57,18 @@ class ModFilter
/**
* Filter the results by the given search term.
*
* @return Builder<Mod>
*/
private function query(string $term): Builder
{
return $this->builder->whereLike('mods.name', "%{$term}%");
return $this->builder->whereLike('mods.name', sprintf('%%%s%%', $term));
}
/**
* Apply the filters to the query.
*
* @return Builder<Mod>
*/
public function apply(): Builder
{
@ -73,6 +83,8 @@ class ModFilter
/**
* Order the query by the given type.
*
* @return Builder<Mod>
*/
private function order(string $type): Builder
{
@ -85,6 +97,8 @@ class ModFilter
/**
* Filter the results by the featured status.
*
* @return Builder<Mod>
*/
private function featured(string $option): Builder
{
@ -97,10 +111,13 @@ class ModFilter
/**
* Filter the results to specific SPT versions.
*
* @param array<int, string> $versions
* @return Builder<Mod>
*/
private function sptVersions(array $versions): Builder
{
return $this->builder->whereExists(function ($query) use ($versions) {
return $this->builder->whereExists(function ($query) use ($versions): void {
$query->select(DB::raw(1))
->from('mod_versions')
->join('mod_version_spt_version', 'mod_versions.id', '=', 'mod_version_spt_version.mod_version_id')

View File

@ -1,13 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class ModFilter extends QueryFilter
{
/**
* The sortable fields.
*
* @var array<int, string>
*/
protected array $sortable = [
'name',
@ -24,6 +29,8 @@ class ModFilter extends QueryFilter
/**
* Filter by ID.
*
* @return Builder<Model>
*/
public function id(string $value): Builder
{
@ -32,6 +39,8 @@ class ModFilter extends QueryFilter
/**
* Filter by hub ID.
*
* @return Builder<Model>
*/
public function hub_id(string $value): Builder
{
@ -40,6 +49,8 @@ class ModFilter extends QueryFilter
/**
* Filter by name.
*
* @return Builder<Model>
*/
public function name(string $value): Builder
{
@ -48,6 +59,8 @@ class ModFilter extends QueryFilter
/**
* Filter by slug.
*
* @return Builder<Model>
*/
public function slug(string $value): Builder
{
@ -56,6 +69,8 @@ class ModFilter extends QueryFilter
/**
* Filter by teaser.
*
* @return Builder<Model>
*/
public function teaser(string $value): Builder
{
@ -64,6 +79,8 @@ class ModFilter extends QueryFilter
/**
* Filter by source code link.
*
* @return Builder<Model>
*/
public function source_code_link(string $value): Builder
{
@ -72,6 +89,8 @@ class ModFilter extends QueryFilter
/**
* Filter by created at date.
*
* @return Builder<Model>
*/
public function created_at(string $value): Builder
{
@ -80,6 +99,8 @@ class ModFilter extends QueryFilter
/**
* Filter by updated at date.
*
* @return Builder<Model>
*/
public function updated_at(string $value): Builder
{
@ -88,6 +109,8 @@ class ModFilter extends QueryFilter
/**
* Filter by published at date.
*
* @return Builder<Model>
*/
public function published_at(string $value): Builder
{
@ -96,6 +119,8 @@ class ModFilter extends QueryFilter
/**
* Filter by featured.
*
* @return Builder<Model>
*/
public function featured(string $value): Builder
{
@ -104,6 +129,8 @@ class ModFilter extends QueryFilter
/**
* Filter by contains ads.
*
* @return Builder<Model>
*/
public function contains_ads(string $value): Builder
{
@ -112,6 +139,8 @@ class ModFilter extends QueryFilter
/**
* Filter by contains AI content.
*
* @return Builder<Model>
*/
public function contains_ai_content(string $value): Builder
{

View File

@ -1,9 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Http\Filters\V1;
use App\Traits\V1\FilterMethods;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
abstract class QueryFilter
@ -15,29 +18,33 @@ abstract class QueryFilter
/**
* The query builder instance.
*
* @var Builder<Model>
*/
protected Builder $builder;
/**
* The request instance.
*/
protected Request $request;
/**
* The sortable fields.
*
* @var array<int, string>
*/
protected array $sortable = [];
/**
* Create a new QueryFilter instance.
*/
public function __construct(Request $request)
{
$this->request = $request;
}
public function __construct(
/**
* The request instance.
*/
protected Request $request
) {}
/**
* Iterate over each of the filter options and call the appropriate method if it exists.
*
* @param array<string, mixed> $filters
* @return Builder<Model>
*/
public function filter(array $filters): Builder
{
@ -52,6 +59,9 @@ abstract class QueryFilter
/**
* Iterate over all request data and call the appropriate method if it exists.
*
* @param Builder<Model> $builder
* @return Builder<Model>
*/
public function apply(Builder $builder): Builder
{

View File

@ -1,13 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class UserFilter extends QueryFilter
{
/**
* The sortable fields.
*
* @var array<int, string>
*/
protected array $sortable = [
'name',
@ -17,6 +22,8 @@ class UserFilter extends QueryFilter
/**
* Filter by ID.
*
* @return Builder<Model>
*/
public function id(string $value): Builder
{
@ -25,6 +32,8 @@ class UserFilter extends QueryFilter
/**
* Filter by name.
*
* @return Builder<Model>
*/
public function name(string $value): Builder
{
@ -33,6 +42,8 @@ class UserFilter extends QueryFilter
/**
* Filter by created at date.
*
* @return Builder<Model>
*/
public function created_at(string $value): Builder
{
@ -41,6 +52,8 @@ class UserFilter extends QueryFilter
/**
* Filter by updated at date.
*
* @return Builder<Model>
*/
public function updated_at(string $value): Builder
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
@ -16,6 +18,8 @@ class LoginUserRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V0;
use Illuminate\Foundation\Http\FormRequest;
@ -16,6 +18,8 @@ class StoreModRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V0;
use Illuminate\Foundation\Http\FormRequest;
@ -16,6 +18,8 @@ class StoreUserRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V0;
use Illuminate\Foundation\Http\FormRequest;
@ -16,6 +18,8 @@ class UpdateModRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V0;
use Illuminate\Foundation\Http\FormRequest;
@ -16,6 +18,8 @@ class UpdateUserRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
@ -8,6 +10,8 @@ class ModRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string>>
*/
public function rules(): array
{

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V0;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin License */
class LicenseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,18 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V0;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin Mod */
class ModResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
$this->load(['users', 'versions', 'license']);
@ -39,7 +47,7 @@ class ModResource extends JsonResource
'published_at' => $this->published_at,
],
'relationships' => [
'users' => $this->users->map(fn ($user) => [
'users' => $this->users->map(fn (User $user): array => [
'data' => [
'type' => 'user',
'id' => $user->id,
@ -48,7 +56,7 @@ class ModResource extends JsonResource
'self' => $user->profileUrl(),
],
])->toArray(),
'versions' => $this->versions->map(fn ($version) => [
'versions' => $this->versions->map(fn (ModVersion $version): array => [
'data' => [
'type' => 'version',
'id' => $version->id,
@ -70,11 +78,11 @@ class ModResource extends JsonResource
'includes' => $this->when(
ApiController::shouldInclude(['users', 'license', 'versions']),
fn () => collect([
'users' => $this->users->map(fn ($user) => new UserResource($user)),
'users' => $this->users->map(fn ($user): UserResource => new UserResource($user)),
'license' => new LicenseResource($this->license),
'versions' => $this->versions->map(fn ($version) => new ModVersionResource($version)),
'versions' => $this->versions->map(fn ($version): ModVersionResource => new ModVersionResource($version)),
])
->filter(fn ($value, $key) => ApiController::shouldInclude($key))
->filter(fn ($value, $key): bool => ApiController::shouldInclude($key))
->flatten(1)
->values()
),

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V0;
use App\Models\ModVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin ModVersion */
class ModVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [
@ -23,10 +29,10 @@ class ModVersionResource extends JsonResource
'version' => $this->version,
// TODO: This should only be visible on the mod version show route(?) which doesn't exist.
//'description' => $this->when(
// 'description' => $this->when(
// $request->routeIs('api.v0.modversion.show'),
// $this->description
//),
// ),
'link' => $this->downloadUrl(absolute: true),
'virus_total_link' => $this->virus_total_link,

View File

@ -1,18 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V0;
use App\Http\Controllers\Api\V0\ApiController;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin User */
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
$this->load('role');

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\Api\V0;
use App\Models\UserRole;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin UserRole */
class UserRoleResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\License;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin License */
class LicenseResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Mod;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin Mod */
class ModResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\ModVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin ModVersion */
class ModVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,17 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\SptVersion;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Override;
/** @mixin SptVersion */
class SptVersionResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
#[Override]
public function toArray(Request $request): array
{
return [

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Import\DataTransferObjects;
class HubUser

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Import;
use App\Exceptions\InvalidVersionNumberException;
@ -34,7 +36,10 @@ use Throwable;
class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function handle(): void
{
@ -86,7 +91,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('wcf1_user_avatar')
->orderBy('avatarID')
->chunk(200, function ($avatars) {
->chunk(200, function ($avatars): void {
$insertData = [];
foreach ($avatars as $avatar) {
$insertData[] = [
@ -97,7 +102,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_user_avatar')->insert($insertData);
}
});
@ -117,7 +122,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('wcf1_user_option_value')
->orderBy('userID')
->chunk(200, function ($options) {
->chunk(200, function ($options): void {
$insertData = [];
foreach ($options as $option) {
$insertData[] = [
@ -126,7 +131,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_user_options_values')->insert($insertData);
}
});
@ -146,7 +151,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('filebase1_file_author')
->orderBy('fileID')
->chunk(200, function ($relationships) {
->chunk(200, function ($relationships): void {
$insertData = [];
foreach ($relationships as $relationship) {
$insertData[] = [
@ -155,7 +160,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_file_author')->insert($insertData);
}
});
@ -176,7 +181,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('filebase1_file_option_value')
->orderBy('fileID')
->chunk(200, function ($options) {
->chunk(200, function ($options): void {
$insertData = [];
foreach ($options as $option) {
$insertData[] = [
@ -186,7 +191,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_file_option_values')->insert($insertData);
}
});
@ -208,7 +213,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('filebase1_file_content')
->orderBy('fileID')
->chunk(200, function ($contents) {
->chunk(200, function ($contents): void {
$insertData = [];
foreach ($contents as $content) {
$insertData[] = [
@ -219,7 +224,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_file_content')->insert($insertData);
}
});
@ -240,7 +245,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
->table('wcf1_label_object')
->where('objectTypeID', 387)
->orderBy('labelID')
->chunk(200, function ($options) {
->chunk(200, function ($options): void {
$insertData = [];
foreach ($options as $option) {
$insertData[] = [
@ -249,7 +254,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_file_version_labels')->insert($insertData);
}
});
@ -269,7 +274,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('filebase1_file_version_content')
->orderBy('versionID')
->chunk(200, function ($options) {
->chunk(200, function ($options): void {
$insertData = [];
foreach ($options as $option) {
$insertData[] = [
@ -278,7 +283,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_file_version_content')->insert($insertData);
}
});
@ -297,7 +302,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
->table('wcf1_label')
->where('groupID', 1)
->orderBy('labelID')
->chunk(100, function (Collection $versions) {
->chunk(100, function (Collection $versions): void {
$insertData = [];
foreach ($versions as $version) {
$insertData[] = [
@ -307,7 +312,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if ($insertData) {
if ($insertData !== []) {
DB::table('temp_spt_version_tags')->insert($insertData);
}
});
@ -320,7 +325,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
// Initialize a cURL handler for downloading mod thumbnails.
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
DB::connection('mysql_hub')
@ -340,9 +345,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'r.rankTitle',
)
->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID')
->chunkById(250, function (Collection $users) use ($curl) {
$userData = $bannedUsers = $userRanks = [];
->chunkById(250, function (Collection $users) use ($curl): void {
$userData = [];
$bannedUsers = [];
$userRanks = [];
foreach ($users as $user) {
$hubUser = new HubUser(
$user->userID,
@ -383,17 +389,19 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Build an array of user data ready to be inserted into the local database.
*
* @return array<string, mixed>
*/
protected function collectUserData(CurlHandle $curl, HubUser $hubUser): array
protected function collectUserData(CurlHandle $curlHandle, HubUser $hubUser): array
{
return [
'hub_id' => (int) $hubUser->userID,
'hub_id' => $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),
'profile_photo_path' => $this->fetchUserAvatar($curlHandle, $hubUser),
'cover_photo_path' => $this->fetchUserCoverPhoto($curlHandle, $hubUser),
'created_at' => $this->cleanRegistrationDate($hubUser->registrationDate),
'updated_at' => now('UTC')->toDateTimeString(),
];
@ -432,16 +440,16 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
// Alright, hear me out... Shut up.
$converter = new HtmlConverter;
$htmlConverter = new HtmlConverter;
$clean = Purify::clean($dirty);
return $converter->convert($clean);
return $htmlConverter->convert($clean);
}
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserAvatar(CurlHandle $curl, HubUser $hubUser): string
protected function fetchUserAvatar(CurlHandle $curlHandle, HubUser $hubUser): string
{
// Fetch the user's avatar data from the temporary table.
$avatar = DB::table('temp_user_avatar')->where('userID', $hubUser->userID)->first();
@ -450,18 +458,18 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
return '';
}
$hashShort = substr($avatar->fileHash, 0, 2);
$hashShort = substr((string) $avatar->fileHash, 0, 2);
$fileName = $avatar->fileHash.'.'.$avatar->avatarExtension;
$hubUrl = 'https://hub.sp-tarkov.com/images/avatars/'.$hashShort.'/'.$avatar->avatarID.'-'.$fileName;
$relativePath = User::profilePhotoStoragePath().'/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
return $this->fetchAndStoreImage($curlHandle, $hubUrl, $relativePath);
}
/**
* Fetch and store an image from the Hub.
*/
protected function fetchAndStoreImage(CurlHandle $curl, string $hubUrl, string $relativePath): string
protected function fetchAndStoreImage(CurlHandle $curlHandle, string $hubUrl, string $relativePath): string
{
// Determine the disk to use based on the environment.
$disk = match (config('app.env')) {
@ -475,11 +483,11 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
}
// Download the image using the cURL handler.
curl_setopt($curl, CURLOPT_URL, $hubUrl);
$image = curl_exec($curl);
curl_setopt($curlHandle, CURLOPT_URL, $hubUrl);
$image = curl_exec($curlHandle);
if ($image === false) {
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curl));
Log::error('There was an error attempting to download the image. cURL error: '.curl_error($curlHandle));
return '';
}
@ -493,9 +501,9 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch the user avatar from the Hub and store it anew.
*/
protected function fetchUserCoverPhoto(CurlHandle $curl, HubUser $hubUser): string
protected function fetchUserCoverPhoto(CurlHandle $curlHandle, HubUser $hubUser): string
{
if (empty($hubUser->coverPhotoHash) || empty($hubUser->coverPhotoExtension)) {
if ($hubUser->coverPhotoHash === null || $hubUser->coverPhotoHash === '' || $hubUser->coverPhotoExtension === null || $hubUser->coverPhotoExtension === '') {
return '';
}
@ -504,7 +512,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$hubUrl = 'https://hub.sp-tarkov.com/images/coverPhotos/'.$hashShort.'/'.$hubUser->userID.'-'.$fileName;
$relativePath = 'user-covers/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
return $this->fetchAndStoreImage($curlHandle, $hubUrl, $relativePath);
}
/**
@ -524,12 +532,14 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Build an array of banned user data ready to be inserted into the local database.
*
* @return array<string, mixed>|null
*/
protected function collectBannedUserData(HubUser $hubUser): ?array
{
if ($hubUser->banned) {
return [
'hub_id' => (int) $hubUser->userID,
'hub_id' => $hubUser->userID,
'comment' => $hubUser->banReason ?? '',
'expired_at' => $this->cleanUnbannedAtDate($hubUser->banExpires),
];
@ -572,7 +582,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
}
return $date->toDateTimeString();
} catch (\Exception $e) {
} catch (Exception) {
// If the date is not valid, return null
return null;
}
@ -580,12 +590,14 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Build an array of user rank data ready to be inserted into the local database.
*
* @return array<string, mixed>|null
*/
protected function collectUserRankData(HubUser $hubUser): ?array
{
if ($hubUser->rankID && $hubUser->rankTitle) {
return [
'hub_id' => (int) $hubUser->userID,
'hub_id' => $hubUser->userID,
'title' => $hubUser->rankTitle,
];
}
@ -595,10 +607,12 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Insert or update the users in the local database.
*
* @param array<array<string, mixed>> $usersData
*/
protected function upsertUsers(array $usersData): void
{
if (! empty($usersData)) {
if ($usersData !== []) {
DB::table('users')->upsert($usersData, ['hub_id'], [
'name',
'email',
@ -611,6 +625,8 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch the hub-banned users from the local database and ban them locally.
*
* @param array<array<string, mixed>> $bannedUsers
*/
protected function handleBannedUsers(array $bannedUsers): void
{
@ -625,13 +641,15 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch or create the user ranks in the local database and assign them to the users.
*
* @param array<array<string, mixed>> $userRanks
*/
protected function handleUserRoles(array $userRanks): void
{
foreach ($userRanks as $userRank) {
$roleName = Str::ucfirst(Str::afterLast($userRank['title'], '.'));
$roleData = $this->buildUserRoleData($roleName);
UserRole::upsert($roleData, ['name'], ['name', 'short_name', 'description', 'color_class']);
UserRole::query()->upsert($roleData, ['name'], ['name', 'short_name', 'description', 'color_class']);
$userRole = UserRole::whereName($roleData['name'])->first();
$user = User::whereHubId($userRank['hub_id'])->first();
@ -641,6 +659,8 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Build the user role data based on the role name.
*
* @return array<string, string>
*/
protected function buildUserRoleData(string $name): array
{
@ -677,7 +697,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
DB::connection('mysql_hub')
->table('wcf1_user_follow')
->select(['followID', 'userID', 'followUserID', 'time'])
->chunkById(100, function (Collection $follows) use (&$followsGroupedByFollower) {
->chunkById(100, function (Collection $follows) use (&$followsGroupedByFollower): void {
foreach ($follows as $follow) {
$followerId = User::whereHubId($follow->userID)->value('id');
$followingId = User::whereHubId($follow->followUserID)->value('id');
@ -694,7 +714,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
}, 'followID');
foreach ($followsGroupedByFollower as $followerId => $followings) {
$user = User::find($followerId);
$user = User::query()->find($followerId);
if ($user) {
$user->following()->sync($followings);
}
@ -708,7 +728,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
DB::connection('mysql_hub')
->table('filebase1_license')
->chunkById(100, function (Collection $licenses) {
->chunkById(100, function (Collection $licenses): void {
$insertData = [];
foreach ($licenses as $license) {
@ -719,35 +739,53 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
];
}
if (! empty($insertData)) {
if ($insertData !== []) {
DB::table('licenses')->upsert($insertData, ['hub_id'], ['name', 'link']);
}
}, 'licenseID');
}
/**
* Import the SPT versions from the Hub database to the local database.
* Import the SPT versions from the public GitHub repo to the local database.
*
* @throws Exception
*/
protected function importSptVersions(): void
{
$domain = config('services.gitea.domain');
$token = config('services.gitea.token');
$url = 'https://api.github.com/repos/sp-tarkov/build/releases';
$token = config('services.github.token');
if (empty($domain) || empty($token)) {
return;
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'User-Agent: The Forge (forge.sp-tarkov.com)',
'Authorization: token '.$token,
]);
$url = "{$domain}/api/v1/repos/SPT/Stable-releases/releases?draft=false&pre-release=false&token={$token}";
$response = curl_exec($ch);
$response = json_decode(file_get_contents($url), true);
throw_if(curl_errno($ch) !== 0, new Exception('cURL Error: '.curl_error($ch)));
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('JSON Decode Error: '.json_last_error_msg());
}
curl_close($ch);
if (empty($response)) {
throw new Exception('No version data found in the API response.');
}
$response = (array) json_decode($response, true);
throw_if(json_last_error() !== JSON_ERROR_NONE, new Exception('JSON Decode Error: '.json_last_error_msg()));
throw_if($response === [], new Exception('No version data found in the GitHub API response.'));
// Filter out drafts and pre-releases.
$response = array_filter($response, fn (array $release): bool => ! $release['draft'] && ! $release['prerelease']);
throw_if($response === [], new Exception('No finalized versions found after filtering drafts and pre-releases.'));
// Ensure that each of the tag_name values has any 'v' prefix trimmed.
$response = array_map(function (array $release) {
$release['tag_name'] = Str::of($release['tag_name'])->ltrim('v')->toString();
return $release;
}, $response);
$latestVersion = $this->getLatestVersion($response);
@ -757,8 +795,8 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'version' => $version['tag_name'],
'link' => $version['html_url'],
'color_class' => $this->detectVersionColor($version['tag_name'], $latestVersion),
'created_at' => Carbon::parse($version['created_at'], 'UTC'),
'updated_at' => Carbon::parse($version['created_at'], 'UTC'),
'created_at' => Carbon::parse($version['published_at'], 'UTC'),
'updated_at' => Carbon::parse($version['published_at'], 'UTC'),
];
}
@ -771,9 +809,9 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'updated_at' => Carbon::now('UTC'),
];
// Upsert won't work here. Do it manually. :(
// Manually update or create
foreach ($insertData as $data) {
$existingVersion = SptVersion::where('version', $data['version'])->first();
$existingVersion = SptVersion::query()->where('version', $data['version'])->first();
if ($existingVersion) {
$existingVersion->update([
'link' => $data['link'],
@ -782,18 +820,20 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'updated_at' => $data['updated_at'],
]);
} else {
SptVersion::create($data);
SptVersion::query()->create($data);
}
}
}
/**
* Get the latest current version from the response data.
*
* @param array<array<string, mixed>> $versions
*/
protected function getLatestVersion(array $versions): string
{
$semanticVersions = array_map(
fn ($version) => $this->extractSemanticVersion($version['tag_name']),
fn ($version): ?string => $this->extractSemanticVersion($version['tag_name']),
$versions
);
@ -848,7 +888,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$major = (int) $major;
$minor = (int) $minor;
if ($major == $currentMajor) {
if ($major === $currentMajor) {
$difference = $currentMinor - $minor;
return match ($difference) {
@ -870,12 +910,12 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
// Initialize a cURL handler for downloading mod thumbnails.
$curl = curl_init();
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
DB::connection('mysql_hub')
->table('filebase1_file')
->chunkById(100, function (Collection $mods) use ($curl) {
->chunkById(100, function (Collection $mods) use ($curl): void {
foreach ($mods as $mod) {
// Fetch any additional authors for the mod.
@ -884,7 +924,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
->pluck('userID')
->toArray();
$modAuthors[] = $mod->userID; // Add the primary author to the list.
$modAuthors = User::whereIn('hub_id', $modAuthors)->pluck('id')->toArray(); // Replace with local IDs.
$modAuthors = User::query()->whereIn('hub_id', $modAuthors)->pluck('id')->toArray(); // Replace with local IDs.
$modContent = DB::table('temp_file_content')
->where('fileID', $mod->fileID)
@ -927,13 +967,13 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$modData[] = [
'hub_id' => (int) $mod->fileID,
'users' => $modAuthors,
'name' => $modContent?->subject ?? '',
'slug' => Str::slug($modContent?->subject ?? ''),
'teaser' => Str::limit($modContent?->teaser ?? '', 255),
'description' => $this->cleanHubContent($modContent?->message ?? ''),
'name' => $modContent->subject ?? '',
'slug' => Str::slug($modContent->subject ?? ''),
'teaser' => Str::limit($modContent->teaser ?? '', 255),
'description' => $this->cleanHubContent($modContent->message ?? ''),
'thumbnail' => $this->fetchModThumbnail($curl, $mod->fileID, $mod->iconHash, $mod->iconExtension),
'license_id' => License::whereHubId($mod->licenseID)->value('id'),
'source_code_link' => $optionSourceCode?->source_code_link ?? '',
'source_code_link' => $optionSourceCode->source_code_link ?? '',
'featured' => (bool) $mod->isFeatured,
'contains_ai_content' => (bool) $optionContainsAi?->contains_ai,
'contains_ads' => (bool) $optionContainsAds?->contains_ads,
@ -948,7 +988,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
// Remove the user_id from the mod data before upserting.
$insertModData = array_map(fn ($mod) => Arr::except($mod, 'users'), $modData);
Mod::withoutGlobalScopes()->upsert($insertModData, ['hub_id'], [
Mod::query()->withoutGlobalScopes()->upsert($insertModData, ['hub_id'], [
'name',
'slug',
'teaser',
@ -978,10 +1018,10 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* Fetch the mod thumbnail from the Hub and store it anew.
*/
protected function fetchModThumbnail(CurlHandle $curl, string $fileID, string $thumbnailHash, string $thumbnailExtension): string
protected function fetchModThumbnail(CurlHandle $curlHandle, string $fileID, string $thumbnailHash, string $thumbnailExtension): string
{
// If any of the required fields are empty, return an empty string.
if (empty($fileID) || empty($thumbnailHash) || empty($thumbnailExtension)) {
if ($fileID === '' || $fileID === '0' || ($thumbnailHash === '' || $thumbnailHash === '0') || ($thumbnailExtension === '' || $thumbnailExtension === '0')) {
return '';
}
@ -991,7 +1031,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
$hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName;
$relativePath = 'mods/'.$fileName;
return $this->fetchAndStoreImage($curl, $hubUrl, $relativePath);
return $this->fetchAndStoreImage($curlHandle, $hubUrl, $relativePath);
}
/**
@ -1001,7 +1041,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
{
DB::connection('mysql_hub')
->table('filebase1_file_version')
->chunkById(500, function (Collection $versions) {
->chunkById(500, function (Collection $versions): void {
foreach ($versions as $version) {
$versionContent = DB::table('temp_file_version_content')
@ -1037,7 +1077,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
try {
$modVersion = new Version($version->versionNumber);
} catch (InvalidVersionNumberException $e) {
} catch (InvalidVersionNumberException) {
$modVersion = new Version('0.0.0');
}
@ -1052,7 +1092,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
'description' => $this->cleanHubContent($versionContent->description ?? ''),
'link' => $version->downloadURL,
'spt_version_constraint' => $sptVersionConstraint,
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
'virus_total_link' => $optionVirusTotal->virus_total_link ?? '',
'downloads' => max((int) $version->downloads, 0), // At least 0.
'disabled' => (bool) $version->isDisabled,
'published_at' => $sptVersionConstraint === '0.0.0' ? null : Carbon::parse($version->uploadTime, 'UTC'),
@ -1062,7 +1102,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
}
if (! empty($insertData)) {
ModVersion::withoutGlobalScopes()->upsert($insertData, ['hub_id'], [
ModVersion::query()->withoutGlobalScopes()->upsert($insertData, ['hub_id'], [
'mod_id',
'version',
'description',
@ -1084,7 +1124,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
*/
private function removeDeletedMods(): void
{
$mods = Mod::select('hub_id')->get();
$mods = Mod::query()->select('hub_id')->get();
foreach ($mods as $mod) {
if (DB::connection('mysql_hub')->table('filebase1_file')->where('fileID', $mod->hub_id)->doesntExist()) {
$mod->delete();
@ -1095,7 +1135,7 @@ class ImportHubDataJob implements ShouldBeUnique, ShouldQueue
/**
* The job failed to process.
*/
public function failed(Throwable $exception): void
public function failed(Throwable $throwable): void
{
// Explicitly drop the temporary tables.
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_user_avatar');

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\ModVersion;
@ -13,7 +15,10 @@ use Illuminate\Queue\SerializesModels;
class ResolveDependenciesJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Resolve the SPT versions for each of the mod versions.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\ModVersion;
@ -13,7 +15,10 @@ use Illuminate\Queue\SerializesModels;
class ResolveSptVersionsJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Resolve the SPT versions for each of the mod versions.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\SptVersion;
@ -12,14 +14,17 @@ use Illuminate\Queue\SerializesModels;
class SptVersionModCountsJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Recalculate the mod counts for each SPT version.
*/
public function handle(): void
{
SptVersion::all()->each(function (SptVersion $sptVersion) {
SptVersion::all()->each(function (SptVersion $sptVersion): void {
$sptVersion->updateModCount();
});
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Mod;
@ -11,14 +13,17 @@ use Illuminate\Queue\SerializesModels;
class UpdateModDownloadsJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Recalculate the total download counts for each mod.
*/
public function handle(): void
{
Mod::with('versions')->chunk(100, function ($mods) {
Mod::with('versions')->chunk(100, function ($mods): void {
foreach ($mods as $mod) {
$mod->calculateDownloads();
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire;
use App\Models\Mod;
@ -19,6 +21,8 @@ class GlobalSearch extends Component
/**
* The search results.
*
* @var array<string, Collection<int, mixed>>
*/
#[Locked]
public array $result = [];
@ -42,6 +46,8 @@ class GlobalSearch extends Component
/**
* Execute the search against each of the searchable models.
*
* @return array<string, Collection<int, mixed>>
*/
protected function executeSearch(string $query): array
{
@ -59,27 +65,37 @@ class GlobalSearch extends Component
/**
* Fetch the user search results.
*
* @return Collection<int, mixed>
*/
protected function fetchUserResults(string $query): Collection
{
return collect(User::search($query)->raw()['hits']);
/** @var Collection<int, mixed> $searchHits */
$searchHits = User::search($query)->raw()['hits'];
return collect($searchHits);
}
/**
* Fetch the mod search results.
*
* @return Collection<int, mixed>
*/
protected function fetchModResults(string $query): Collection
{
return collect(Mod::search($query)->raw()['hits']);
/** @var Collection<int, mixed> $searchHits */
$searchHits = Mod::search($query)->raw()['hits'];
return collect($searchHits);
}
/**
* Count the total number of results across all models.
*
* @param array<string, Collection<int, mixed>> $results
*/
protected function countTotalResults(array $results): int
{
return collect($results)->reduce(function (int $carry, Collection $result) {
return $carry + $result->count();
}, 0);
return (int) collect($results)->reduce(fn (int $carry, Collection $result): int => $carry + $result->count(), 0);
}
}

View File

@ -1,8 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Mod;
use App\Http\Filters\ModFilter;
use App\Models\Mod;
use App\Models\SptVersion;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\View\View;
@ -42,12 +45,16 @@ class Listing extends Component
/**
* The options that are available for the per page setting.
*
* @var array<int>
*/
#[Locked]
public array $perPageOptions = [6, 12, 24, 50];
/**
* The SPT versions filter value.
*
* @var array<int, string>
*/
#[Session]
#[Url]
@ -72,15 +79,15 @@ class Listing extends Component
*/
public function mount(): void
{
$this->activeSptVersions = $this->activeSptVersions ?? Cache::remember('active-spt-versions', 60 * 60, function () {
return SptVersion::getVersionsForLastThreeMinors();
});
$this->activeSptVersions ??= Cache::remember('active-spt-versions', 60 * 60, fn (): Collection => SptVersion::getVersionsForLastThreeMinors());
$this->sptVersions = $this->sptVersions ?? $this->getDefaultSptVersions();
$this->sptVersions ??= $this->getDefaultSptVersions();
}
/**
* Get the default values for the SPT Versions filter.
*
* @return array<int, string>
*/
protected function getDefaultSptVersions(): array
{
@ -89,12 +96,12 @@ class Listing extends Component
/**
* Get all patch versions of the latest minor SPT version.
*
* @return Collection<int, SptVersion>
*/
public function getLatestMinorVersions(): Collection
{
return $this->activeSptVersions->filter(function (SptVersion $sptVersion) {
return $sptVersion->isLatestMinor();
});
return $this->activeSptVersions->filter(fn (SptVersion $sptVersion): bool => $sptVersion->isLatestMinor());
}
/**
@ -112,11 +119,11 @@ class Listing extends Component
'sptVersions' => $this->sptVersions,
];
$mods = (new ModFilter($filters))->apply()->paginate($this->perPage);
$lengthAwarePaginator = (new ModFilter($filters))->apply()->paginate($this->perPage);
$this->redirectOutOfBoundsPage($mods);
$this->redirectOutOfBoundsPage($lengthAwarePaginator);
return view('livewire.mod.listing', compact('mods'));
return view('livewire.mod.listing', ['mods' => $lengthAwarePaginator]);
}
/**
@ -139,11 +146,13 @@ class Listing extends Component
/**
* Check if the current page is greater than the last page. Redirect if it is.
*
* @param LengthAwarePaginator<Mod> $lengthAwarePaginator
*/
private function redirectOutOfBoundsPage(LengthAwarePaginator $mods): void
private function redirectOutOfBoundsPage(LengthAwarePaginator $lengthAwarePaginator): void
{
if ($mods->currentPage() > $mods->lastPage()) {
$this->redirectRoute('mods', ['page' => $mods->lastPage()]);
if ($lengthAwarePaginator->currentPage() > $lengthAwarePaginator->lastPage()) {
$this->redirectRoute('mods', ['page' => $lengthAwarePaginator->lastPage()]);
}
}
@ -167,11 +176,11 @@ class Listing extends Component
if ($this->query !== '') {
$count++;
}
if ($this->featured !== 'include') {
$count++;
}
$count += count($this->sptVersions);
return $count;
return $count + count($this->sptVersions);
}
}

View File

@ -1,14 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Profile;
use App\Models\User;
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
class ManageOauthConnections extends Component
{
use AuthorizesRequests;
@ -16,33 +19,31 @@ class ManageOAuthConnections extends Component
* Store the current user.
*/
#[Locked]
public $user;
public User $user;
/**
* Controls the confirmation modal visibility.
*/
public $confirmingConnectionDeletion = false;
public bool $confirmingConnectionDeletion = false;
/**
* Stores the ID of the connection to be deleted.
*/
#[Locked]
public $selectedConnectionId;
public ?string $selectedConnectionId = null;
/**
* 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
public function confirmConnectionDeletion(string $connectionId): void
{
$this->confirmingConnectionDeletion = true;
$this->selectedConnectionId = $connectionId;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Profile;
use App\Actions\Fortify\PasswordValidationRules;
@ -21,14 +23,14 @@ class UpdatePasswordForm extends JetstreamUpdatePasswordForm
* 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
public function updatePassword(UpdatesUserPasswords $updatesUserPasswords): void
{
$this->resetErrorBag();
$user = Auth::user();
if ($user->password !== null) {
parent::updatePassword($updater);
parent::updatePassword($updatesUserPasswords);
} else {
// User has a null password. Allow them to set a new password without their current password.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Profile;
use Illuminate\Http\RedirectResponse;
@ -7,11 +9,14 @@ use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
use Laravel\Jetstream\Http\Livewire\UpdateProfileInformationForm;
use Livewire\Features\SupportRedirects\Redirector;
use Override;
class UpdateProfileForm extends UpdateProfileInformationForm
{
/**
* The new cover photo for the user.
*
* @var string|null
*/
public $cover;
@ -38,6 +43,7 @@ class UpdateProfileForm extends UpdateProfileInformationForm
/**
* Update the user's profile information.
*/
#[Override]
public function updateProfileInformation(UpdatesUserProfileInformation $updater): RedirectResponse|Redirector|null
{
$this->resetErrorBag();
@ -51,7 +57,7 @@ class UpdateProfileForm extends UpdateProfileInformationForm
])) : $this->state
);
if (isset($this->photo) || isset($this->cover)) {
if ($this->photo !== null || $this->cover !== null) {
return redirect()->route('profile.show');
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire\User;
use Illuminate\View\View;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire\User;
use App\Models\User;
@ -34,18 +36,6 @@ class FollowCard extends Component
*/
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.
*/
@ -65,18 +55,24 @@ class FollowCard extends Component
/**
* A collection of user IDs that the auth user follows.
*
* @var Collection<int, int>
*/
#[Locked]
public Collection $authFollowIds;
/**
* The profile user's followers (or following).
*
* @var Collection<int, User>
*/
#[Locked]
public Collection $followUsers;
/**
* The events the component should listen for.
*
* @var array<string, string>
*/
protected $listeners = ['refreshComponent' => '$refresh'];

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Livewire\User;
use App\Models\User;
@ -19,6 +21,8 @@ class FollowCards extends Component
/**
* A collection of user IDs that the auth user follows.
*
* @var Collection<int, int>
*/
#[Locked]
public Collection $authFollowIds;

View File

@ -1,21 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\LicenseFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* License Model
*
* @property int $id
* @property int|null $hub_id
* @property string $name
* @property string $link
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property-read Collection<int, Mod> $mods
*/
class License extends Model
{
/** @use HasFactory<LicenseFactory> */
use HasFactory;
use SoftDeletes;
/**
* The relationship between a license and mod.
*
* @return HasMany<Mod>
* @return HasMany<Mod, $this>
*/
public function mods(): HasMany
{

View File

@ -1,12 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
use App\Models\Scopes\PublishedScope;
use App\Traits\CanModerate;
use Database\Factories\ModFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -14,21 +18,53 @@ 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\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable;
use Override;
/**
* Mod Model
*
* @property int $id
* @property int|null $hub_id
* @property string $name
* @property string $slug
* @property string $teaser
* @property string $description
* @property string $thumbnail
* @property int|null $license_id
* @property int $downloads
* @property string $source_code_link
* @property bool $featured
* @property bool $contains_ai_content
* @property bool $contains_ads
* @property bool $disabled
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $deleted_at
* @property Carbon|null $published_at
* @property-read License|null $license
* @property-read Collection<int, User> $users
* @property-read Collection<int, ModVersion> $versions
* @property-read ModVersion|null $latestVersion
* @property-read ModVersion|null $latestUpdatedVersion
*/
class Mod extends Model
{
use CanModerate;
/** @use HasFactory<ModFactory> */
use HasFactory;
use Searchable;
use SoftDeletes;
/**
* Post boot method to configure the model.
*/
#[Override]
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope);
@ -60,7 +96,7 @@ class Mod extends Model
/**
* The relationship between a mod and its users.
*
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this>
*/
public function users(): BelongsToMany
{
@ -70,7 +106,7 @@ class Mod extends Model
/**
* The relationship between a mod and its license.
*
* @return BelongsTo<License, Mod>
* @return BelongsTo<License, $this>
*/
public function license(): BelongsTo
{
@ -80,7 +116,7 @@ class Mod extends Model
/**
* The relationship between a mod and its last updated version.
*
* @return HasOne<ModVersion>
* @return HasOne<ModVersion, $this>
*/
public function latestUpdatedVersion(): HasOne
{
@ -93,7 +129,7 @@ class Mod extends Model
/**
* The relationship between a mod and its versions.
*
* @return HasMany<ModVersion>
* @return HasMany<ModVersion, $this>
*/
public function versions(): HasMany
{
@ -107,6 +143,8 @@ class Mod extends Model
/**
* The data that is searchable by Scout.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
@ -122,9 +160,9 @@ class Mod extends Model
'description' => $this->description,
'thumbnail' => $this->thumbnail,
'featured' => $this->featured,
'created_at' => strtotime($this->created_at),
'updated_at' => strtotime($this->updated_at),
'published_at' => strtotime($this->published_at),
'created_at' => $this->created_at->timestamp,
'updated_at' => $this->updated_at->timestamp,
'published_at' => $this->published_at->timestamp,
'latestVersion' => $this->latestVersion->latestSptVersion->version_formatted,
'latestVersionColorClass' => $this->latestVersion->latestSptVersion->color_class,
];
@ -162,21 +200,16 @@ class Mod extends Model
}
// 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;
}
$activeSptVersions = Cache::remember('active-spt-versions', 60 * 60, fn (): Collection => SptVersion::getVersionsForLastThreeMinors());
// All conditions are met; the mod should be searchable.
return true;
return in_array($this->latestVersion->latestSptVersion->version, $activeSptVersions->pluck('version')->toArray());
}
/**
* The relationship between a mod and its latest version.
*
* @return HasOne<ModVersion>
* @return HasOne<ModVersion, $this>
*/
public function latestVersion(): HasOne
{
@ -193,14 +226,14 @@ class Mod extends Model
/**
* Build the URL to the mod's thumbnail.
*
* @return Attribute<string, never>
*/
public function thumbnailUrl(): Attribute
{
return Attribute::get(function (): string {
return $this->thumbnail
? Storage::disk($this->thumbnailDisk())->url($this->thumbnail)
: '';
});
return Attribute::get(fn (): string => $this->thumbnail
? Storage::disk($this->thumbnailDisk())->url($this->thumbnail)
: '');
}
/**
@ -216,10 +249,13 @@ class Mod extends Model
/**
* Scope a query by applying QueryFilter filters.
*
* @param Builder<Model> $builder
* @return Builder<Model>
*/
public function scopeFilter(Builder $builder, QueryFilter $filters): Builder
public function scopeFilter(Builder $builder, QueryFilter $queryFilter): Builder
{
return $filters->apply($builder);
return $queryFilter->apply($builder);
}
/**

View File

@ -1,20 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\ModDependencyFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* ModDependency Model
*
* @property int $id
* @property int $mod_version_id
* @property int $dependent_mod_id
* @property string $constraint
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read ModVersion $modVersion
* @property-read Mod $dependentMod
* @property-read Collection<int, ModResolvedDependency> $resolvedDependencies
*/
class ModDependency extends Model
{
/** @use HasFactory<ModDependencyFactory> */
use HasFactory;
/**
* The relationship between the mod dependency and the mod version.
*
* @return BelongsTo<ModVersion, ModDependency>
* @return BelongsTo<ModVersion, $this>
*/
public function modVersion(): BelongsTo
{
@ -24,7 +43,7 @@ class ModDependency extends Model
/**
* The relationship between the mod dependency and the resolved dependency.
*
* @return HasMany<ModResolvedDependency>
* @return HasMany<ModResolvedDependency, $this>
*/
public function resolvedDependencies(): HasMany
{
@ -35,7 +54,7 @@ class ModDependency extends Model
/**
* The relationship between the mod dependency and the dependent mod.
*
* @return BelongsTo<Mod, ModDependency>
* @return BelongsTo<Mod, $this>
*/
public function dependentMod(): BelongsTo
{

View File

@ -1,16 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* ModResolvedDependency Model
*
* @property int $id
* @property int $mod_version_id
* @property int $dependency_id
* @property int $resolved_mod_version_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read ModVersion|null $modVersion
* @property-read ModDependency|null $dependency
* @property-read ModVersion|null $resolvedModVersion
*/
class ModResolvedDependency extends Model
{
/**
* The relationship between the resolved dependency and the mod version.
*
* @return BelongsTo<ModVersion, ModResolvedDependency>
* @return BelongsTo<ModVersion, $this>
*/
public function modVersion(): BelongsTo
{
@ -20,7 +36,7 @@ class ModResolvedDependency extends Model
/**
* The relationship between the resolved dependency and the dependency.
*
* @return BelongsTo<ModDependency, ModResolvedDependency>
* @return BelongsTo<ModDependency, $this>
*/
public function dependency(): BelongsTo
{
@ -30,7 +46,7 @@ class ModResolvedDependency extends Model
/**
* The relationship between the resolved dependency and the resolved mod version.
*
* @return BelongsTo<ModVersion, ModResolvedDependency>
* @return BelongsTo<ModVersion, $this>
*/
public function resolvedModVersion(): BelongsTo
{

View File

@ -1,11 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Exceptions\InvalidVersionNumberException;
use App\Models\Scopes\PublishedScope;
use App\Support\Version;
use App\Traits\CanModerate;
use Database\Factories\ModVersionFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -13,39 +17,74 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Carbon;
use Override;
/**
* ModVersion Model
*
* @property int $id
* @property int|null $hub_id
* @property int $mod_id
* @property string $version
* @property int $version_major
* @property int $version_minor
* @property int $version_patch
* @property string $version_pre_release
* @property string $description
* @property string $link
* @property string $spt_version_constraint
* @property string $virus_total_link
* @property int $downloads
* @property bool $disabled
* @property Carbon|null $deleted_at
* @property Carbon|null $published_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Mod $mod
* @property-read Collection<int, ModDependency> $dependencies
* @property-read Collection<int, ModVersion> $resolvedDependencies
* @property-read Collection<int, ModVersion> $latestResolvedDependencies
* @property-read SptVersion|null $latestSptVersion
* @property-read Collection<int, SptVersion> $sptVersions
*/
class ModVersion extends Model
{
use CanModerate;
/** @use HasFactory<ModVersionFactory> */
use HasFactory;
use SoftDeletes;
/**
* Update the parent mod's updated_at timestamp when the mod version is updated.
*
* @var string[]
*/
protected $touches = ['mod'];
/**
* Post boot method to configure the model.
*/
#[Override]
protected static function booted(): void
{
static::addGlobalScope(new PublishedScope);
static::saving(function (ModVersion $model) {
static::saving(function (ModVersion $modVersion): void {
// Extract the version sections from the version string.
try {
$version = new Version($model->version);
$version = new Version($modVersion->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 = '';
$modVersion->version_major = $version->getMajor();
$modVersion->version_minor = $version->getMinor();
$modVersion->version_patch = $version->getPatch();
$modVersion->version_pre_release = $version->getPreRelease();
} catch (InvalidVersionNumberException) {
$modVersion->version_major = 0;
$modVersion->version_minor = 0;
$modVersion->version_patch = 0;
$modVersion->version_pre_release = '';
}
});
}
@ -53,7 +92,7 @@ class ModVersion extends Model
/**
* The relationship between a mod version and mod.
*
* @return BelongsTo<Mod, ModVersion>
* @return BelongsTo<Mod, $this>
*/
public function mod(): BelongsTo
{
@ -63,7 +102,7 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its dependencies.
*
* @return HasMany<ModDependency>
* @return HasMany<ModDependency, $this>
*/
public function dependencies(): HasMany
{
@ -74,7 +113,7 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its resolved dependencies.
*
* @return BelongsToMany<ModVersion>
* @return BelongsToMany<ModVersion, $this>
*/
public function resolvedDependencies(): BelongsToMany
{
@ -86,13 +125,13 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its each of it's resolved dependencies' latest versions.
*
* @return BelongsToMany<ModVersion>
* @return BelongsToMany<ModVersion, $this>
*/
public function latestResolvedDependencies(): BelongsToMany
{
return $this->belongsToMany(ModVersion::class, 'mod_resolved_dependencies', 'mod_version_id', 'resolved_mod_version_id')
->withPivot('dependency_id')
->join('mod_versions as latest_versions', function ($join) {
->join('mod_versions as latest_versions', function ($join): void {
$join->on('latest_versions.id', '=', 'mod_versions.id')
->whereRaw('latest_versions.version = (SELECT MAX(mv.version) FROM mod_versions mv WHERE mv.mod_id = mod_versions.mod_id)');
})
@ -103,7 +142,7 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its latest SPT version.
*
* @return HasOneThrough<SptVersion>
* @return HasOneThrough<SptVersion, ModVersionSptVersion, $this>
*/
public function latestSptVersion(): HasOneThrough
{
@ -118,7 +157,7 @@ class ModVersion extends Model
/**
* The relationship between a mod version and its SPT versions.
*
* @return BelongsToMany<SptVersion>
* @return BelongsToMany<SptVersion, $this>
*/
public function sptVersions(): BelongsToMany
{

View File

@ -1,9 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\Pivot;
/**
* ModVersionSptVersion Pivot Model
*
* @property int $id
* @property int $mod_version_id
* @property int $spt_version_id
* @property-read ModVersion $modVersion
* @property-read SptVersion $sptVersion
*/
class ModVersionSptVersion extends Pivot
{
public $incrementing = true;

View File

@ -1,17 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\OAuthConnectionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* OAuthConnection Model
*
* @property int $id
* @property int $user_id
* @property string $provider
* @property string $provider_id
* @property string $token
* @property string $refresh_token
* @property string $nickname
* @property string $name
* @property string $email
* @property string $avatar
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read User $user
*/
class OAuthConnection extends Model
{
/** @use HasFactory<OAuthConnectionFactory> */
use HasFactory;
protected $table = 'oauth_connections';
/**
* The relationship between the OAuth connection and the user.
*
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class DisabledScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*
* @param Builder<Model> $builder
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable().'.disabled', false);
}
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
@ -10,6 +12,8 @@ class PublishedScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*
* @param Builder<Model> $builder
*/
public function apply(Builder $builder, Model $model): void
{

View File

@ -1,23 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Exceptions\InvalidVersionNumberException;
use App\Support\Version;
use Carbon\Carbon;
use Database\Factories\SptVersionFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Cache;
use Override;
use Throwable;
/**
* SptVersion Model
*
* @property int $id
* @property int|null $hub_id
* @property string $version
* @property int $version_major
* @property int $version_minor
* @property int $version_patch
* @property string $version_pre_release
* @property int $mod_count
* @property string $link
* @property string $color_class
* @property Carbon|null $deleted_at
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Collection<int, ModVersion> $modVersions
* @property-read string $version_formatted
*/
class SptVersion extends Model
{
/** @use HasFactory<SptVersionFactory> */
use HasFactory;
use SoftDeletes;
/**
* Get all versions for the last three minor versions.
*
* @return Collection<int, $this>
*/
public static function getVersionsForLastThreeMinors(): Collection
{
@ -28,7 +57,7 @@ class SptVersion extends Model
$minorVersions = array_column($lastThreeMinorVersions, 'minor');
// Fetch all versions for the last three minor versions with mod count.
return self::select(['spt_versions.id', 'spt_versions.version', 'spt_versions.color_class', 'spt_versions.mod_count'])
return self::query()->select(['spt_versions.id', 'spt_versions.version', 'spt_versions.color_class', 'spt_versions.mod_count'])
->join('mod_version_spt_version', 'spt_versions.id', '=', 'mod_version_spt_version.spt_version_id')
->join('mod_versions', 'mod_version_spt_version.mod_version_id', '=', 'mod_versions.id')
->join('mods', 'mod_versions.mod_id', '=', 'mods.id')
@ -46,29 +75,31 @@ class SptVersion extends Model
/**
* Get the last three minor versions (major.minor format).
*
* @return array<int, array{major: int, minor: int}>
*/
public static function getLastThreeMinorVersions(): array
{
return self::selectRaw('CONCAT(version_major, ".", version_minor) AS minor_version, version_major, version_minor')
return self::query()->selectRaw('CONCAT(version_major, ".", version_minor) AS minor_version, version_major, version_minor')
->where('version', '!=', '0.0.0')
->groupBy('version_major', 'version_minor')
->orderByDesc('version_major')
->orderByDesc('version_minor')
->limit(3)
->get()
->map(function (SptVersion $version) {
return [
'major' => (int) $version->version_major,
'minor' => (int) $version->version_minor,
];
})
->map(fn (SptVersion $sptVersion): array => [
'major' => (int) $sptVersion->version_major,
'minor' => (int) $sptVersion->version_minor,
])
->toArray();
}
/**
* Extract the version sections from the version string.
*
* @throws InvalidVersionNumberException
* @return array{major: int, minor: int, patch: int, pre_release: string}
*
* @throws InvalidVersionNumberException|Throwable
*/
public static function extractVersionSections(string $version): array
{
@ -77,9 +108,7 @@ class SptVersion extends Model
// Perform the regex match to capture the version sections, including the possible preRelease section.
preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([a-zA-Z0-9]+))?$/', $version, $matches);
if (! $matches) {
throw new InvalidVersionNumberException('Invalid SPT version number: '.$version);
}
throw_if($matches === [], new InvalidVersionNumberException('Invalid SPT version number: '.$version));
return [
'major' => $matches[1] ?? 0,
@ -92,22 +121,23 @@ class SptVersion extends Model
/**
* Called when the model is booted.
*/
#[Override]
protected static function booted(): void
{
static::saving(function (SptVersion $model) {
static::saving(function (SptVersion $sptVersion): void {
// Extract the version sections from the version string.
try {
$version = new Version($model->version);
$version = new Version($sptVersion->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 = '';
$sptVersion->version_major = $version->getMajor();
$sptVersion->version_minor = $version->getMinor();
$sptVersion->version_patch = $version->getPatch();
$sptVersion->version_pre_release = $version->getPreRelease();
} catch (InvalidVersionNumberException) {
$sptVersion->version_major = 0;
$sptVersion->version_minor = 0;
$sptVersion->version_patch = 0;
$sptVersion->version_pre_release = '';
}
});
}
@ -128,7 +158,7 @@ class SptVersion extends Model
/**
* The relationship between an SPT version and mod version.
*
* @return BelongsToMany<ModVersion>
* @return BelongsToMany<ModVersion, $this>
*/
public function modVersions(): BelongsToMany
{
@ -152,7 +182,7 @@ class SptVersion extends Model
{
$latestVersion = self::getLatest();
if (! $latestVersion) {
if (! $latestVersion instanceof \App\Models\SptVersion) {
return false;
}
@ -166,11 +196,11 @@ class SptVersion extends Model
*/
public static function getLatest(): ?SptVersion
{
return Cache::remember('latest_spt_version', 300, function () {
return SptVersion::select(['version', 'version_major', 'version_minor', 'version_patch', 'version_pre_release'])
->orderByDesc('version')
->first();
});
return Cache::remember('latest_spt_version', 300, fn () => \App\Models\SptVersion::query()->select(['version', 'version_major', 'version_minor', 'version_patch', 'version_pre_release'])
->orderByDesc('version_major')
->orderByDesc('version_minor')
->orderByDesc('version_patch')
->first());
}
/**

View File

@ -1,15 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
use App\Notifications\ResetPassword;
use App\Notifications\VerifyEmail;
use App\Traits\HasCoverPhoto;
use Carbon\Carbon;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
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;
@ -21,13 +27,39 @@ use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Searchable;
use Mchev\Banhammer\Traits\Bannable;
use SensitiveParameter;
/**
* @property int $id
* @property int|null $hub_id
* @property int|null $discord_id
* @property string $name
* @property string $email
* @property Carbon|null $email_verified_at
* @property string|null $password
* @property string $about
* @property int|null $user_role_id
* @property string|null $remember_token
* @property string|null $profile_photo_path
* @property string|null $cover_photo_path
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read string $profile_photo_url
* @property-read UserRole|null $role
* @property-read Collection<int, Mod> $mods
* @property-read Collection<int, User> $followers
* @property-read Collection<int, User> $following
* @property-read Collection<int, OAuthConnection> $oAuthConnections
*/
class User extends Authenticatable implements MustVerifyEmail
{
use Bannable;
use HasApiTokens;
use HasCoverPhoto;
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasProfilePhoto;
use Notifiable;
use Searchable;
@ -55,7 +87,7 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The relationship between a user and their mods.
*
* @return BelongsToMany<Mod>
* @return BelongsToMany<Mod, $this>
*/
public function mods(): BelongsToMany
{
@ -65,7 +97,7 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The relationship between a user and users that follow them.
*
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this>
*/
public function followers(): BelongsToMany
{
@ -91,7 +123,7 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The relationship between a user and users they follow.
*
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this>
*/
public function following(): BelongsToMany
{
@ -123,6 +155,8 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The data that is searchable by Scout.
*
* @return array<string, mixed>
*/
public function toSearchableArray(): array
{
@ -177,7 +211,7 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* Overwritten to instead use the queued version of the ResetPassword notification.
*/
public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void
public function sendPasswordResetNotification(#[SensitiveParameter] $token): void
{
$this->notify(new ResetPassword($token));
}
@ -204,9 +238,9 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* Assign a role to the user.
*/
public function assignRole(UserRole $role): bool
public function assignRole(UserRole $userRole): bool
{
$this->role()->associate($role);
$this->role()->associate($userRole);
return $this->save();
}
@ -214,7 +248,7 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* The relationship between a user and their role.
*
* @return BelongsTo<UserRole, User>
* @return BelongsTo<UserRole, $this>
*/
public function role(): BelongsTo
{
@ -223,14 +257,19 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* Scope a query by applying QueryFilter filters.
*
* @param Builder<Model> $builder
* @return Builder<Model>
*/
public function scopeFilter(Builder $builder, QueryFilter $filters): Builder
public function scopeFilter(Builder $builder, QueryFilter $queryFilter): Builder
{
return $filters->apply($builder);
return $queryFilter->apply($builder);
}
/**
* The relationship between a user and their OAuth providers.
*
* @return HasMany<OAuthConnection, $this>
*/
public function oAuthConnections(): HasMany
{
@ -239,6 +278,8 @@ class User extends Authenticatable implements MustVerifyEmail
/**
* Handle the about default value if empty. Thanks, MySQL!
*
* @return Attribute<string[], never>
*/
protected function about(): Attribute
{

View File

@ -1,19 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Database\Factories\UserRoleFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* UserRole Model
*
* @property int $id
* @property string $name
* @property string $short_name
* @property string $description
* @property string $color_class
* @property Carbon $created_at
* @property Carbon $updated_at
* @property-read Collection<int, User> $users
*/
class UserRole extends Model
{
/** @use HasFactory<UserRoleFactory> */
use HasFactory;
/**
* The relationship between a user role and users.
*
* @return HasMany<User>
* @return HasMany<User, $this>
*/
public function users(): HasMany
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Auth\Notifications\ResetPassword as OriginalResetPassword;
@ -20,6 +22,8 @@ class ResetPassword extends OriginalResetPassword implements ShouldQueue
/**
* Get the array representation of the notification.
*
* @return array<int, mixed>
*/
public function toArray(object $notifiable): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail as OriginalVerifyEmail;
@ -15,6 +17,8 @@ class VerifyEmail extends OriginalVerifyEmail implements ShouldQueue
/**
* Get the array representation of the notification.
*
* @return array<int, mixed>
*/
public function toArray(object $notifiable): array
{

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ModDependency;
@ -7,12 +9,7 @@ use App\Services\DependencyVersionService;
class ModDependencyObserver
{
protected DependencyVersionService $dependencyVersionService;
public function __construct(DependencyVersionService $dependencyVersionService)
{
$this->dependencyVersionService = $dependencyVersionService;
}
public function __construct(protected DependencyVersionService $dependencyVersionService) {}
/**
* Handle the ModDependency "saved" event.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\Mod;
@ -7,13 +9,7 @@ use App\Services\DependencyVersionService;
class ModObserver
{
protected DependencyVersionService $dependencyVersionService;
public function __construct(
DependencyVersionService $dependencyVersionService,
) {
$this->dependencyVersionService = $dependencyVersionService;
}
public function __construct(protected DependencyVersionService $dependencyVersionService) {}
/**
* Handle the Mod "saved" event.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ModVersion;
@ -8,17 +10,7 @@ use App\Services\SptVersionService;
class ModVersionObserver
{
protected DependencyVersionService $dependencyVersionService;
protected SptVersionService $sptVersionService;
public function __construct(
DependencyVersionService $dependencyVersionService,
SptVersionService $sptVersionService,
) {
$this->dependencyVersionService = $dependencyVersionService;
$this->sptVersionService = $sptVersionService;
}
public function __construct(protected DependencyVersionService $dependencyVersionService, protected SptVersionService $sptVersionService) {}
/**
* Handle the ModVersion "saved" event.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\ModVersion;
@ -7,12 +9,7 @@ use App\Services\SptVersionService;
class SptVersionObserver
{
protected SptVersionService $sptVersionService;
public function __construct(SptVersionService $sptVersionService)
{
$this->sptVersionService = $sptVersionService;
}
public function __construct(protected SptVersionService $sptVersionService) {}
/**
* Handle the SptVersion "saved" event.

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\Mod;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\ModVersion;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\OAuthConnection;

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Policies;
use App\Models\User;

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