mirror of
https://github.com/sp-tarkov/forge.git
synced 2025-02-13 04:30:41 -05:00
Merge branch 'develop'
This commit is contained in:
commit
9ab747b19e
10
.env.ci
10
.env.ci
@ -11,13 +11,13 @@ LOG_LEVEL=debug
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=33306
|
||||
DB_DATABASE=test
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=testing
|
||||
DB_USERNAME=user
|
||||
DB_PASSWORD=password
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
SCOUT_DRIVER=null
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
CACHE_DRIVER=array
|
||||
CACHE_STORE=array
|
||||
EMAIL_DRIVER=array
|
||||
SESSION_DRIVER=array
|
||||
|
37
.env.example
37
.env.example
@ -3,7 +3,7 @@ APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://forge.test
|
||||
APP_URL=http://localhost
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
@ -18,21 +18,22 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Due to the hub import script, only MySQL is supported at this time.
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=localhost
|
||||
DB_HOST=mysql
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=forge
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
DB_USERNAME=forge
|
||||
DB_PASSWORD=password
|
||||
|
||||
# This is only needed if you are running the app:import-hub command.
|
||||
# For normal development you should just seed the database with fake data:
|
||||
# `php artisan migrate:fresh --seed`
|
||||
DB_HUB_CONNECTION=mysql
|
||||
DB_HUB_HOST=localhost
|
||||
DB_HUB_PORT=3306
|
||||
DB_HUB_DATABASE=forge
|
||||
DB_HUB_USERNAME=root
|
||||
DB_HUB_HOST=
|
||||
DB_HUB_PORT=
|
||||
DB_HUB_DATABASE=
|
||||
DB_HUB_USERNAME=
|
||||
DB_HUB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
@ -43,32 +44,32 @@ SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
ASSET_URL=http://localhost/storage
|
||||
|
||||
CACHE_STORE=redis
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
REDIS_QUEUE=default
|
||||
REDIS_CACHE_CONNECTION=cache
|
||||
REDIS_QUEUE_CONNECTION=queue
|
||||
REDIS_QUEUE=queue
|
||||
|
||||
SCOUT_DRIVER=meilisearch
|
||||
SCOUT_QUEUE=true
|
||||
|
||||
MEILISEARCH_HOST=http://127.0.0.1:7700
|
||||
MEILISEARCH_KEY=LARAVEL-HERD
|
||||
SCOUT_DRIVER=meilisearch
|
||||
MEILISEARCH_HOST=http://meilisearch:7700
|
||||
MEILISEARCH_KEY=
|
||||
MEILISEARCH_NO_ANALYTICS=true
|
||||
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=${APP_NAME}
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
NOVA_LICENSE_KEY=
|
||||
|
||||
SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
97
.github/README.md
vendored
Normal file
97
.github/README.md
vendored
Normal file
@ -0,0 +1,97 @@
|
||||
<p align="center"><a href="https://forge.sp-tarkov.com" target="_blank"><img src="logo.spt.png" width="400" alt="Single Player Tarkov Logo"></a></p>
|
||||
<h1 align="center"><em>The Forge</em></h1>
|
||||
<p align="center">
|
||||
<a href="https://www.mozilla.org/en-US/MPL/2.0/"><img src="https://img.shields.io/badge/License-MPL_2.0-blue.svg" alt="License: MPL 2.0"></a>
|
||||
<a href="https://github.com/sp-tarkov/forge/actions/workflows/quality.yaml"><img src="https://github.com/sp-tarkov/forge/actions/workflows/quality.yaml/badge.svg" alt="Quality Control Action Status"></a>
|
||||
<a href="https://github.com/sp-tarkov/forge/actions/workflows/tests.yaml"><img src="https://github.com/sp-tarkov/forge/actions/workflows/tests.yaml/badge.svg" alt="Test Action Status"></a>
|
||||
<a href="https://discord.com/invite/Xn9msqQZan"><img src="https://img.shields.io/badge/Chat-Discord-5865F2?logo=discord&logoColor=ffffff" alt="Discord Chat"></a>
|
||||
<a href="https://www.patreon.com/sptarkov"><img src="https://img.shields.io/badge/Fund-Patreon-fe3c71?logo=patreon&logoColor=ffffff" alt="Patreon Fund"></a>
|
||||
</p>
|
||||
|
||||
The Forge is a Laravel-based web application that provides a platform for the Single Player Tarkov community to share and discover user-generated content, such as mods, guides, and other tools. It is currently under heavy development. Please review this entire document before attempting to contribute, especially the "Development Discussion" section.
|
||||
|
||||
## Development Environment Setup
|
||||
|
||||
This is a [Laravel](https://laravel.com/docs/11.x) project that uses [Sail](https://laravel.com/docs/11.x/sail), which provides a Docker-based development environment. Ensure you review the Sail documentation for useage, particularly in a [Windows environment](https://laravel.com/docs/11.x/installation#sail-on-windows), as WSL2 is recommended.
|
||||
|
||||
### Accessing the Application:
|
||||
|
||||
Once the Docker containers are running with Sail you can access the application at <http://localhost>.
|
||||
|
||||
### Available Services:
|
||||
|
||||
| Service | Access Via Application | Access Via Host |
|
||||
|-------------|------------------------|------------------|
|
||||
| MySQL | `mysql:3306` | `localhost:3306` |
|
||||
| Redis | `redis:6379` | `localhost:6379` |
|
||||
| Meilisearch | `meilisearch:7700` | `localhost:7700` |
|
||||
| Mailpit | `mailpit:1025` | `localhost:8025` |
|
||||
|
||||
### Notable Routes
|
||||
|
||||
| Service | Authentication | Access Via Host |
|
||||
|----------------------------------|----------------|----------------------------|
|
||||
| Administration Panel (Nova) | Via User Role | <http://localhost/nova> |
|
||||
| Redis Queue Management (Horizon) | Via User Role | <http://localhost/horizon> |
|
||||
| Website Status (Pulse) | Via User Role | <http://localhost/pulse> |
|
||||
| Meilisearch WebUI | Local Only | <http://localhost:7700> |
|
||||
| Mailpit WebUI | Local Only | <http://localhost:8025> |
|
||||
|
||||
Most of these connection settings should already be configured in the `.env.example` file. Simply save the `.env.example` file as `.env` and adjust further settings as needed.
|
||||
|
||||
### Basic Usage Examples
|
||||
|
||||
Here are some basic commands to get started with Forge:
|
||||
|
||||
```
|
||||
# View all of the available Artisan commands:
|
||||
./vendor/bin/sail artisan
|
||||
```
|
||||
|
||||
```
|
||||
# Migrate and seed the database with test data:
|
||||
./vendor/bin/sail artisan migrate:fresh –seed
|
||||
```
|
||||
|
||||
```
|
||||
# Run Laravel Horizon (the queue monitor):
|
||||
./vendor/bin/sail artisan horizon
|
||||
```
|
||||
|
||||
```
|
||||
# Start the local
|
||||
./vendor/bin/sail artisan horizon
|
||||
```
|
||||
|
||||
### 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 through the following channels...*
|
||||
|
||||
You may propose new features or improvements of existing Forge behavior in [the repository's GitHub discussion board](https://github.com/sp-tarkov/forge/discussions). If you propose a new feature, please be willing to implement at least some of the code that would be needed to complete the feature.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Forge, please email Refringe at me@refringe.com. All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
BIN
.github/logo.spt.png
vendored
Normal file
BIN
.github/logo.spt.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
65
.github/workflows/larastan.yml
vendored
65
.github/workflows/larastan.yml
vendored
@ -1,65 +0,0 @@
|
||||
name: Larastan Static Analysis
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**'
|
||||
|
||||
jobs:
|
||||
larastan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: |
|
||||
composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: actions-cache
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Cache PHP dependencies
|
||||
uses: actions/cache@v4
|
||||
id: vendor-cache
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Copy .env
|
||||
run: php -r "file_exists('.env') || copy('.env.ci', '.env');"
|
||||
|
||||
- name: Install Dependencies
|
||||
if: steps.vendor-cache.outputs.cache-hit != 'true'
|
||||
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
|
||||
- name: Update Dependencies with latest stable
|
||||
run: composer update --prefer-stable
|
||||
|
||||
- name: Execute Code Static Analysis with Larastan
|
||||
run: |
|
||||
composer require --dev larastan/larastan
|
||||
./vendor/bin/phpstan analyse -c ./phpstan.neon --no-progress
|
73
.github/workflows/pint.yml
vendored
73
.github/workflows/pint.yml
vendored
@ -1,73 +0,0 @@
|
||||
name: Pint Code Style Fixer
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'dependabot/**'
|
||||
|
||||
jobs:
|
||||
pint-fixer:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: |
|
||||
composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: actions-cache
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Cache PHP dependencies
|
||||
uses: actions/cache@v4
|
||||
id: vendor-cache
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Install Dependencies
|
||||
if: steps.vendor-cache.outputs.cache-hit != 'true'
|
||||
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
|
||||
- name: Update Dependencies with latest stable
|
||||
run: composer update --prefer-stable
|
||||
|
||||
- name: Run Pint Code Style Fixer
|
||||
run: |
|
||||
composer require laravel/pint --dev
|
||||
./vendor/bin/pint
|
||||
|
||||
- uses: stefanzweifel/git-auto-commit-action@v5
|
||||
with:
|
||||
commit_message: Pint PHP Style Fixes
|
||||
commit_user_name: Pint Bot
|
||||
skip_fetch: true
|
||||
file_pattern: '*.php'
|
98
.github/workflows/quality.yaml
vendored
Normal file
98
.github/workflows/quality.yaml
vendored
Normal file
@ -0,0 +1,98 @@
|
||||
name: Quality
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
security-checker:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: The PHP Security Checker
|
||||
uses: symfonycorp/security-checker-action@v5
|
||||
|
||||
larastan:
|
||||
runs-on: ubuntu-24.04
|
||||
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: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
- 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 config:cache
|
||||
php artisan route:cache
|
||||
- name: Clear Laravel Config
|
||||
run: php artisan config:clear
|
||||
- name: Execute Code Static Analysis with Larastan
|
||||
run: ./vendor/bin/phpstan analyse -c ./phpstan.neon --no-progress --error-format=github
|
||||
|
||||
pint-fixer:
|
||||
runs-on: ubuntu-24.04
|
||||
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: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
- 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 config:cache
|
||||
php artisan route:cache
|
||||
- name: Clear Laravel Config
|
||||
run: php artisan config:clear
|
||||
- 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'
|
16
.github/workflows/security.yml
vendored
16
.github/workflows/security.yml
vendored
@ -1,16 +0,0 @@
|
||||
name: Security Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
security-check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Security Checker
|
||||
uses: symfonycorp/security-checker-action@v5
|
72
.github/workflows/tests.yaml
vendored
Normal file
72
.github/workflows/tests.yaml
vendored
Normal file
@ -0,0 +1,72 @@
|
||||
name: Tests
|
||||
|
||||
on: [ push, pull_request ]
|
||||
|
||||
jobs:
|
||||
laravel-tests:
|
||||
runs-on: ubuntu-24.04
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.3
|
||||
env:
|
||||
MYSQL_DATABASE: testing
|
||||
MYSQL_USER: user
|
||||
MYSQL_PASSWORD: password
|
||||
MYSQL_ROOT_PASSWORD: password
|
||||
ports:
|
||||
- 33306:3306
|
||||
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
|
||||
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: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
- name: Cache composer dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: ${{ runner.os }}-composer-
|
||||
- name: Install Composer Dependencies
|
||||
run: composer install --no-progress --prefer-dist --optimize-autoloader
|
||||
- name: Get NPM Cache Directory
|
||||
id: npm-cache
|
||||
run: echo "NPM_CACHE_DIR=$(npm config get cache)" >> $GITHUB_ENV
|
||||
- name: Cache NPM Dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.NPM_CACHE_DIR }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
- name: Build Front-end Assets
|
||||
run: npm run build
|
||||
- name: Prepare Laravel Environment
|
||||
run: |
|
||||
php -r "file_exists('.env') || copy('.env.ci', '.env');"
|
||||
php artisan key:generate
|
||||
php artisan config:cache
|
||||
php artisan route:cache
|
||||
- name: Run Database Migrations
|
||||
run: php artisan migrate
|
||||
- name: Link Storage
|
||||
run: php artisan storage:link
|
||||
- name: Run Tests
|
||||
run: php artisan test
|
||||
- name: Display Laravel Log
|
||||
if: failure()
|
||||
run: cat storage/logs/laravel.log
|
118
.github/workflows/tests.yml
vendored
118
.github/workflows/tests.yml
vendored
@ -1,118 +0,0 @@
|
||||
name: Laravel Pest Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
laravel-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.3
|
||||
env:
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
||||
MYSQL_DATABASE: test
|
||||
ports:
|
||||
- 33306:3306
|
||||
options: >-
|
||||
--health-cmd="mysqladmin ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache node_modules Directory
|
||||
uses: actions/cache@v4
|
||||
id: node_modules-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('**/package.json') }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Install NPM Packages
|
||||
if: steps.node_modules-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build Frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Configure Laravel Nova Authentication
|
||||
shell: bash
|
||||
env:
|
||||
NOVA_USERNAME: ${{ secrets.NOVA_USERNAME }}
|
||||
NOVA_LICENSE_KEY: ${{ secrets.NOVA_LICENSE_KEY }}
|
||||
run: |
|
||||
composer config http-basic.nova.laravel.com "$NOVA_USERNAME" "$NOVA_LICENSE_KEY"
|
||||
|
||||
- name: Get Composer Cache Directory
|
||||
id: composer-cache
|
||||
run: |
|
||||
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: actions-cache
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Cache PHP dependencies
|
||||
uses: actions/cache@v4
|
||||
id: vendor-cache
|
||||
with:
|
||||
path: vendor
|
||||
key: ${{ runner.OS }}-build-${{ hashFiles('**/composer.lock') }}
|
||||
|
||||
- name: Copy .env
|
||||
run: php -r "file_exists('.env') || copy('.env.ci', '.env');"
|
||||
|
||||
- name: Install Dependencies
|
||||
if: steps.vendor-cache.outputs.cache-hit != 'true'
|
||||
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||
|
||||
- name: Update Dependencies with latest stable
|
||||
run: composer update --prefer-stable
|
||||
|
||||
- name: Generate key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Directory Permissions
|
||||
run: chmod -R 777 storage bootstrap/cache
|
||||
|
||||
- name: Run Migrations
|
||||
env:
|
||||
DB_CONNECTION: mysql
|
||||
DB_DATABASE: test
|
||||
DB_PORT: 33306
|
||||
DB_USER: root
|
||||
run: php artisan migrate
|
||||
|
||||
- name: Execute Unit & Feature Tests
|
||||
env:
|
||||
DB_CONNECTION: mysql
|
||||
DB_DATABASE: test
|
||||
DB_PORT: 33306
|
||||
DB_USER: root
|
||||
run: |
|
||||
composer require pestphp/pest --dev --with-all-dependencies
|
||||
./vendor/bin/pest
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -17,3 +17,4 @@ yarn-error.log
|
||||
/.fleet
|
||||
/.idea
|
||||
/.vscode
|
||||
.DS_Store
|
||||
|
373
LICENSE
Normal file
373
LICENSE
Normal file
@ -0,0 +1,373 @@
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
@ -2,20 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\Mod;
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use App\Jobs\ImportHubData;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Benchmark;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
|
||||
class ImportHub extends Command
|
||||
{
|
||||
@ -23,464 +11,11 @@ class ImportHub extends Command
|
||||
|
||||
protected $description = 'Connects to the Hub database and imports the data into the Laravel database.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
// This may take a minute or two...
|
||||
set_time_limit(0);
|
||||
// Add the ImportHubData job to the queue.
|
||||
ImportHubData::dispatch()->onQueue('long');
|
||||
|
||||
$this->newLine();
|
||||
|
||||
$totalTime = Benchmark::value(function () {
|
||||
$loadDataTime = Benchmark::value(function () {
|
||||
$this->loadData();
|
||||
});
|
||||
$this->info('Execution time: '.round($loadDataTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
|
||||
$importUsersTime = Benchmark::value(function () {
|
||||
$this->importUsers();
|
||||
});
|
||||
$this->info('Execution time: '.round($importUsersTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
|
||||
$importLicensesTime = Benchmark::value(function () {
|
||||
$this->importLicenses();
|
||||
});
|
||||
$this->info('Execution time: '.round($importLicensesTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
|
||||
$importSptVersionsTime = Benchmark::value(function () {
|
||||
$this->importSptVersions();
|
||||
});
|
||||
$this->info('Execution time: '.round($importSptVersionsTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
|
||||
$importModsTime = Benchmark::value(function () {
|
||||
$this->importMods();
|
||||
});
|
||||
$this->info('Execution time: '.round($importModsTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
|
||||
$importModVersionsTime = Benchmark::value(function () {
|
||||
$this->importModVersions();
|
||||
});
|
||||
$this->info('Execution time: '.round($importModVersionsTime[1], 2).'ms');
|
||||
$this->newLine();
|
||||
});
|
||||
|
||||
// Disconnect from the Hub database, clearing temporary tables.
|
||||
DB::connection('mysql_hub')->disconnect();
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Data imported successfully');
|
||||
$this->info('Total execution time: '.round($totalTime[1], 2).'ms');
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Refreshing Meilisearch indexes...');
|
||||
$this->call('scout:delete-all-indexes');
|
||||
$this->call('scout:sync-index-settings');
|
||||
$this->call('scout:import', ['model' => '\App\Models\Mod']);
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Done');
|
||||
}
|
||||
|
||||
protected function loadData(): void
|
||||
{
|
||||
// We're just going to dump a few things in memory to escape the N+1 problem.
|
||||
$this->output->write('Loading data into memory... ');
|
||||
$this->bringFileOptionsLocal();
|
||||
$this->bringFileContentLocal();
|
||||
$this->bringFileVersionLabelsLocal();
|
||||
$this->bringFileVersionContentLocal();
|
||||
$this->info('Done.');
|
||||
}
|
||||
|
||||
protected function importUsers(): void
|
||||
{
|
||||
$totalInserted = 0;
|
||||
|
||||
foreach (DB::connection('mysql_hub')->table('wcf1_user')->orderBy('userID')->cursor() as $wolt) {
|
||||
$registrationDate = Carbon::parse($wolt->registrationDate, 'UTC');
|
||||
if ($registrationDate->isFuture()) {
|
||||
$registrationDate = now('UTC');
|
||||
}
|
||||
$registrationDate->setTimezone('UTC');
|
||||
|
||||
$insertData = [
|
||||
'hub_id' => $wolt->userID,
|
||||
'name' => $wolt->username,
|
||||
'email' => mb_convert_case($wolt->email, MB_CASE_LOWER, 'UTF-8'),
|
||||
'password' => $this->cleanPasswordHash($wolt->password),
|
||||
'created_at' => $registrationDate,
|
||||
'updated_at' => now('UTC')->toDateTimeString(),
|
||||
];
|
||||
|
||||
User::upsert($insertData, ['hub_id'], ['name', 'email', 'password', 'created_at', 'updated_at']);
|
||||
$totalInserted++;
|
||||
|
||||
// Log every 2500 users processed
|
||||
if ($totalInserted % 2500 == 0) {
|
||||
$this->line('Processed 2500 users. Total processed so far: '.$totalInserted);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info('Total users processed: '.$totalInserted);
|
||||
}
|
||||
|
||||
protected function cleanPasswordHash(string $password): string
|
||||
{
|
||||
// The hub passwords are hashed sometimes with a prefix of the hash type. We only want the hash.
|
||||
// If it's not Bcrypt, they'll have to reset their password. Tough luck.
|
||||
return str_replace(['Bcrypt:', 'cryptMD5:', 'cryptMD5::'], '', $password);
|
||||
}
|
||||
|
||||
protected function importLicenses(): void
|
||||
{
|
||||
$totalInserted = 0;
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_license')
|
||||
->chunkById(100, function (Collection $licenses) use (&$totalInserted) {
|
||||
$insertData = [];
|
||||
foreach ($licenses as $license) {
|
||||
$insertData[] = [
|
||||
'hub_id' => $license->licenseID,
|
||||
'name' => $license->licenseName,
|
||||
'link' => $license->licenseURL,
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
DB::table('licenses')->upsert($insertData, ['hub_id'], ['name', 'link']);
|
||||
$totalInserted += count($insertData);
|
||||
$this->line('Processed '.count($insertData).' licenses. Total processed so far: '.$totalInserted);
|
||||
}
|
||||
|
||||
unset($insertData);
|
||||
unset($licenses);
|
||||
}, 'licenseID');
|
||||
|
||||
$this->info('Total licenses processed: '.$totalInserted);
|
||||
}
|
||||
|
||||
protected function importSptVersions(): void
|
||||
{
|
||||
$totalInserted = 0;
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label')
|
||||
->where('groupID', 1)
|
||||
->chunkById(100, function (Collection $versions) use (&$totalInserted) {
|
||||
$insertData = [];
|
||||
foreach ($versions as $version) {
|
||||
$insertData[] = [
|
||||
'hub_id' => $version->labelID,
|
||||
'version' => $version->label,
|
||||
'color_class' => $this->translateColour($version->cssClassName),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']);
|
||||
$totalInserted += count($insertData);
|
||||
$this->line('Processed '.count($insertData).' SPT Versions. Total processed so far: '.$totalInserted);
|
||||
}
|
||||
|
||||
unset($insertData);
|
||||
unset($versions);
|
||||
}, 'labelID');
|
||||
|
||||
$this->info('Total licenses processed: '.$totalInserted);
|
||||
}
|
||||
|
||||
protected function translateColour(string $colour = ''): string
|
||||
{
|
||||
return match ($colour) {
|
||||
'green' => 'green',
|
||||
'slightly-outdated' => 'lime',
|
||||
'yellow' => 'yellow',
|
||||
'red' => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
protected function importMods(): void
|
||||
{
|
||||
$command = $this;
|
||||
$totalInserted = 0;
|
||||
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file')
|
||||
->chunkById(100, function (Collection $mods) use (&$command, &$curl, &$totalInserted) {
|
||||
|
||||
foreach ($mods as $mod) {
|
||||
|
||||
$modContent = DB::table('temp_file_content')
|
||||
->where('fileID', $mod->fileID)
|
||||
->orderBy('fileID', 'desc')
|
||||
->first();
|
||||
|
||||
$optionSourceCode = DB::table('temp_file_option_values')
|
||||
->select('optionValue as source_code_link')
|
||||
->where('fileID', $mod->fileID)
|
||||
->whereIn('optionID', [5, 1])
|
||||
->whereNot('optionValue', '')
|
||||
->orderByDesc('optionID')
|
||||
->first();
|
||||
|
||||
$optionContainsAi = DB::table('temp_file_option_values')
|
||||
->select('optionValue as contains_ai')
|
||||
->where('fileID', $mod->fileID)
|
||||
->where('optionID', 7)
|
||||
->whereNot('optionValue', '')
|
||||
->first();
|
||||
|
||||
$optionContainsAds = DB::table('temp_file_option_values')
|
||||
->select('optionValue as contains_ads')
|
||||
->where('fileID', $mod->fileID)
|
||||
->where('optionID', 3)
|
||||
->whereNot('optionValue', '')
|
||||
->first();
|
||||
|
||||
$versionLabel = DB::table('temp_file_version_labels')
|
||||
->select('labelID')
|
||||
->where('objectID', $mod->fileID)
|
||||
->orderBy('labelID', 'desc')
|
||||
->first();
|
||||
|
||||
if (empty($versionLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $mod->fileID,
|
||||
'user_id' => User::whereHubId($mod->userID)->value('id'),
|
||||
'name' => $modContent?->subject ?? '',
|
||||
'slug' => Str::slug($modContent?->subject) ?? '',
|
||||
'teaser' => Str::limit($modContent?->teaser) ?? '',
|
||||
'description' => $this->convertModDescription($modContent?->message ?? ''),
|
||||
'thumbnail' => $this->fetchModThumbnail($command, $curl, $mod->fileID, $mod->iconHash, $mod->iconExtension),
|
||||
'license_id' => License::whereHubId($mod->licenseID)->value('id'),
|
||||
'source_code_link' => $optionSourceCode?->source_code_link ?? '',
|
||||
'featured' => (bool) $mod->isFeatured,
|
||||
'contains_ai_content' => (bool) $optionContainsAi?->contains_ai ?? false,
|
||||
'contains_ads' => (bool) $optionContainsAds?->contains_ads ?? false,
|
||||
'disabled' => (bool) $mod->isDisabled,
|
||||
'created_at' => Carbon::parse($mod->time, 'UTC'),
|
||||
'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
Mod::upsert($insertData, ['hub_id'], ['user_id', 'name', 'slug', 'teaser', 'description', 'thumbnail', 'license_id', 'source_code_link', 'featured', 'contains_ai_content', 'disabled', 'created_at', 'updated_at']);
|
||||
$totalInserted += count($insertData);
|
||||
$command->line('Processed '.count($insertData).' mods. Total processed so far: '.$totalInserted);
|
||||
}
|
||||
|
||||
unset($insertData);
|
||||
unset($mods);
|
||||
}, 'fileID');
|
||||
|
||||
curl_close($curl);
|
||||
|
||||
$this->info('Total mods processed: '.$totalInserted);
|
||||
}
|
||||
|
||||
protected function bringFileOptionsLocal(): void
|
||||
{
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (
|
||||
fileID INT,
|
||||
optionID INT,
|
||||
optionValue VARCHAR(255)
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_option_value')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_option_values')->insert([
|
||||
'fileID' => $option->fileID,
|
||||
'optionID' => $option->optionID,
|
||||
'optionValue' => $option->optionValue,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function bringFileContentLocal(): void
|
||||
{
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_content (
|
||||
fileID INT,
|
||||
subject VARCHAR(255),
|
||||
teaser VARCHAR(255),
|
||||
message LONGTEXT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_content')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($contents) {
|
||||
foreach ($contents as $content) {
|
||||
DB::table('temp_file_content')->insert([
|
||||
'fileID' => $content->fileID,
|
||||
'subject' => $content->subject,
|
||||
'teaser' => $content->teaser,
|
||||
'message' => $content->message,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function fetchModThumbnail($command, $curl, string $fileID, string $thumbnailHash, string $thumbnailExtension): string
|
||||
{
|
||||
if (empty($fileID) || empty($thumbnailHash) || empty($thumbnailExtension)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Only the first two characters of the icon hash.
|
||||
$hashShort = substr($thumbnailHash, 0, 2);
|
||||
|
||||
$hubUrl = "https://hub.sp-tarkov.com/files/images/file/$hashShort/$fileID.$thumbnailExtension";
|
||||
$relativePath = "mods/$thumbnailHash.$thumbnailExtension";
|
||||
|
||||
// Check to make sure the image doesn't already exist.
|
||||
if (Storage::exists($relativePath)) {
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
$command->output->write("Downloading mod thumbnail: $hubUrl... ");
|
||||
curl_setopt($curl, CURLOPT_URL, $hubUrl);
|
||||
$image = curl_exec($curl);
|
||||
if ($image === false) {
|
||||
$command->error('Error: '.curl_error($curl));
|
||||
} else {
|
||||
Storage::put($relativePath, $image);
|
||||
$command->info('Done.');
|
||||
}
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
protected function importModVersions(): void
|
||||
{
|
||||
$command = $this;
|
||||
$totalInserted = 0;
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_version')
|
||||
->chunkById(500, function (Collection $versions) use (&$command, &$totalInserted) {
|
||||
|
||||
foreach ($versions as $version) {
|
||||
|
||||
$versionContent = DB::table('temp_file_version_content')
|
||||
->select('description')
|
||||
->where('versionID', $version->versionID)
|
||||
->orderBy('versionID', 'desc')
|
||||
->first();
|
||||
|
||||
$optionVirusTotal = DB::table('temp_file_option_values')
|
||||
->select('optionValue as virus_total_link')
|
||||
->where('fileID', $version->fileID)
|
||||
->whereIn('optionID', [6, 2])
|
||||
->whereNot('optionValue', '')
|
||||
->orderByDesc('optionID')
|
||||
->first();
|
||||
|
||||
$versionLabel = DB::table('temp_file_version_labels')
|
||||
->select('labelID')
|
||||
->where('objectID', $version->fileID)
|
||||
->orderBy('labelID', 'desc')
|
||||
->first();
|
||||
|
||||
$modId = Mod::whereHubId($version->fileID)->value('id');
|
||||
|
||||
if (empty($versionLabel) || empty($modId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'hub_id' => $version->versionID,
|
||||
'mod_id' => $modId,
|
||||
'version' => $version->versionNumber,
|
||||
'description' => $this->convertModDescription($versionContent->description ?? ''),
|
||||
'link' => $version->downloadURL,
|
||||
'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'),
|
||||
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
|
||||
'downloads' => max((int) $version->downloads, 0), // Ensure the value is at least 0
|
||||
'disabled' => (bool) $version->isDisabled,
|
||||
'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
ModVersion::upsert($insertData, ['hub_id'], ['mod_id', 'version', 'description', 'link', 'spt_version_id', 'virus_total_link', 'downloads', 'created_at', 'updated_at']);
|
||||
$totalInserted += count($insertData);
|
||||
$command->line('Processed '.count($insertData).' mod versions. Total processed so far: '.$totalInserted);
|
||||
}
|
||||
|
||||
unset($insertData);
|
||||
unset($version);
|
||||
}, 'versionID');
|
||||
|
||||
$this->info('Total mod versions processed: '.$totalInserted);
|
||||
}
|
||||
|
||||
protected function bringFileVersionLabelsLocal(): void
|
||||
{
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (
|
||||
labelID INT,
|
||||
objectID INT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label_object')
|
||||
->where('objectTypeID', 387)
|
||||
->orderBy('labelID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_labels')->insert([
|
||||
'labelID' => $option->labelID,
|
||||
'objectID' => $option->objectID,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function bringFileVersionContentLocal(): void
|
||||
{
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (
|
||||
versionID INT,
|
||||
description TEXT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_version_content')
|
||||
->orderBy('versionID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_content')->insert([
|
||||
'versionID' => $option->versionID,
|
||||
'description' => $option->description,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function convertModDescription(string $description): string
|
||||
{
|
||||
// Alright, hear me out... Shut up.
|
||||
$converter = new HtmlConverter();
|
||||
|
||||
return $converter->convert(Purify::clean($description));
|
||||
$this->info('The import job has been added to the queue.');
|
||||
}
|
||||
}
|
||||
|
@ -8,24 +8,15 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class UploadAssets extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:upload-assets';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Uploads the Vite build assets to Cloudflare R2';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
* This command uploads the Vite build assets to Cloudflare R2. Typically, this will be run after the assets have
|
||||
* been built and the application is ready to deploy from within the production environment build process.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
$this->info('Publishing assets...');
|
||||
|
||||
|
678
app/Jobs/ImportHubData.php
Normal file
678
app/Jobs/ImportHubData.php
Normal file
@ -0,0 +1,678 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\License;
|
||||
use App\Models\Mod;
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use Carbon\Carbon;
|
||||
use CurlHandle;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use League\HTMLToMarkdown\HtmlConverter;
|
||||
use Stevebauman\Purify\Facades\Purify;
|
||||
|
||||
class ImportHubData implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
// Stream some data locally so that we don't have to keep accessing the Hub's database. Use MySQL temporary
|
||||
// tables to store the data to save on memory; we don't want this to be a hog.
|
||||
$this->bringFileOptionsLocal();
|
||||
$this->bringFileContentLocal();
|
||||
$this->bringFileVersionLabelsLocal();
|
||||
$this->bringFileVersionContentLocal();
|
||||
|
||||
// Begin to import the data into the permanent local database tables.
|
||||
$this->importUsers();
|
||||
$this->importLicenses();
|
||||
$this->importSptVersions();
|
||||
$this->importMods();
|
||||
$this->importModVersions();
|
||||
|
||||
// Ensure that we've disconnected from the Hub database, clearing temporary tables.
|
||||
DB::connection('mysql_hub')->disconnect();
|
||||
|
||||
// Reindex the Meilisearch index.
|
||||
Artisan::call('scout:delete-all-indexes');
|
||||
Artisan::call('scout:sync-index-settings');
|
||||
Artisan::call('scout:import', ['model' => '\App\Models\Mod']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the file options from the Hub database to the local database temporary table.
|
||||
*/
|
||||
protected function bringFileOptionsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_option_values (
|
||||
fileID INT,
|
||||
optionID INT,
|
||||
optionValue VARCHAR(255)
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_option_value')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_option_values')->insert([
|
||||
'fileID' => (int) $option->fileID,
|
||||
'optionID' => (int) $option->optionID,
|
||||
'optionValue' => $option->optionValue,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the file content from the Hub database to the local database temporary table.
|
||||
*/
|
||||
protected function bringFileContentLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_content (
|
||||
fileID INT,
|
||||
subject VARCHAR(255),
|
||||
teaser VARCHAR(255),
|
||||
message LONGTEXT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_content')
|
||||
->orderBy('fileID')
|
||||
->chunk(200, function ($contents) {
|
||||
foreach ($contents as $content) {
|
||||
DB::table('temp_file_content')->insert([
|
||||
'fileID' => (int) $content->fileID,
|
||||
'subject' => $content->subject,
|
||||
'teaser' => $content->teaser,
|
||||
'message' => $content->message,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the file version labels from the Hub database to the local database temporary table.
|
||||
*/
|
||||
protected function bringFileVersionLabelsLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_labels (
|
||||
labelID INT,
|
||||
objectID INT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label_object')
|
||||
->where('objectTypeID', 387)
|
||||
->orderBy('labelID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_labels')->insert([
|
||||
'labelID' => (int) $option->labelID,
|
||||
'objectID' => (int) $option->objectID,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring the file version content from the Hub database to the local database temporary table.
|
||||
*/
|
||||
protected function bringFileVersionContentLocal(): void
|
||||
{
|
||||
DB::statement('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
|
||||
DB::statement('CREATE TEMPORARY TABLE temp_file_version_content (
|
||||
versionID INT,
|
||||
description TEXT
|
||||
)');
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_version_content')
|
||||
->orderBy('versionID')
|
||||
->chunk(200, function ($options) {
|
||||
foreach ($options as $option) {
|
||||
DB::table('temp_file_version_content')->insert([
|
||||
'versionID' => (int) $option->versionID,
|
||||
'description' => $option->description,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the users from the Hub database to the local database.
|
||||
*/
|
||||
protected function importUsers(): void
|
||||
{
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_user as u')
|
||||
->select('u.userID', 'u.username', 'u.email', 'u.password', 'u.registrationDate', 'u.banned', 'u.banReason', 'u.banExpires', 'u.rankID', 'r.rankTitle')
|
||||
->leftJoin('wcf1_user_rank as r', 'u.rankID', '=', 'r.rankID')
|
||||
->chunkById(250, function (Collection $users) {
|
||||
$userData = $bannedUsers = $userRanks = [];
|
||||
|
||||
foreach ($users as $user) {
|
||||
$userData[] = $this->collectUserData($user);
|
||||
|
||||
$bannedUserData = $this->collectBannedUserData($user);
|
||||
if ($bannedUserData) {
|
||||
$bannedUsers[] = $bannedUserData;
|
||||
}
|
||||
|
||||
$userRankData = $this->collectUserRankData($user);
|
||||
if ($userRankData) {
|
||||
$userRanks[] = $userRankData;
|
||||
}
|
||||
}
|
||||
|
||||
$this->upsertUsers($userData);
|
||||
$this->handleBannedUsers($bannedUsers);
|
||||
$this->handleUserRoles($userRanks);
|
||||
}, 'userID');
|
||||
}
|
||||
|
||||
protected function collectUserData($user): array
|
||||
{
|
||||
return [
|
||||
'hub_id' => (int) $user->userID,
|
||||
'name' => $user->username,
|
||||
'email' => mb_convert_case($user->email, MB_CASE_LOWER, 'UTF-8'),
|
||||
'password' => $this->cleanPasswordHash($user->password),
|
||||
'created_at' => $this->cleanRegistrationDate($user->registrationDate),
|
||||
'updated_at' => now('UTC')->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the password hash from the Hub database.
|
||||
*/
|
||||
protected function cleanPasswordHash(string $password): string
|
||||
{
|
||||
// The hub passwords sometimes hashed with a prefix of the hash type. We only want the hash.
|
||||
// If it's not Bcrypt, they'll have to reset their password. Tough luck.
|
||||
$clean = str_ireplace(['invalid:', 'bcrypt:', 'bcrypt::', 'cryptmd5:', 'cryptmd5::'], '', $password);
|
||||
|
||||
// At this point, if the password hash starts with $2, it's a valid Bcrypt hash. Otherwise, it's invalid.
|
||||
return str_starts_with($clean, '$2') ? $clean : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the registration date from the Hub database.
|
||||
*/
|
||||
protected function cleanRegistrationDate(string $registrationDate): string
|
||||
{
|
||||
$date = Carbon::createFromTimestamp($registrationDate);
|
||||
|
||||
// If the registration date is in the future, set it to now.
|
||||
if ($date->isFuture()) {
|
||||
$date = Carbon::now('UTC');
|
||||
}
|
||||
|
||||
return $date->toDateTimeString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an array of banned user data ready to be inserted into the local database.
|
||||
*/
|
||||
protected function collectBannedUserData($user): ?array
|
||||
{
|
||||
if ($user->banned) {
|
||||
return [
|
||||
'hub_id' => (int) $user->userID,
|
||||
'comment' => $user->banReason ?? '',
|
||||
'expired_at' => $this->cleanUnbannedAtDate($user->banExpires),
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean the banned_at date from the Hub database.
|
||||
*/
|
||||
protected function cleanUnbannedAtDate(?string $unbannedAt): ?string
|
||||
{
|
||||
// If the input is null, return null
|
||||
if ($unbannedAt === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit check for the Unix epoch start date
|
||||
if (Str::contains($unbannedAt, '1970-01-01')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use validator to check for a valid date format
|
||||
$validator = Validator::make(['date' => $unbannedAt], [
|
||||
'date' => 'date_format:Y-m-d H:i:s',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
// If the date format is invalid, return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Validate the date using Carbon
|
||||
try {
|
||||
$date = Carbon::parse($unbannedAt);
|
||||
|
||||
// Additional check to ensure the date is not a default or zero date
|
||||
if ($date->year == 0 || $date->month == 0 || $date->day == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->toDateTimeString();
|
||||
} catch (\Exception $e) {
|
||||
// If the date is not valid, return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Build an array of user rank data ready to be inserted into the local database.
|
||||
*/
|
||||
protected function collectUserRankData($user): ?array
|
||||
{
|
||||
if ($user->rankID && $user->rankTitle) {
|
||||
return [
|
||||
'hub_id' => (int) $user->userID,
|
||||
'title' => $user->rankTitle,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert or update the users in the local database.
|
||||
*/
|
||||
protected function upsertUsers($usersData): void
|
||||
{
|
||||
if (! empty($usersData)) {
|
||||
DB::table('users')->upsert($usersData, ['hub_id'], [
|
||||
'name',
|
||||
'email',
|
||||
'password',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the hub-banned users from the local database and ban them locally.
|
||||
*/
|
||||
protected function handleBannedUsers($bannedUsers): void
|
||||
{
|
||||
foreach ($bannedUsers as $bannedUser) {
|
||||
$user = User::whereHubId($bannedUser['hub_id'])->first();
|
||||
$user->ban([
|
||||
'comment' => $bannedUser['comment'],
|
||||
'expired_at' => $bannedUser['expired_at'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch or create the user ranks in the local database and assign them to the users.
|
||||
*/
|
||||
protected function handleUserRoles($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 = UserRole::whereName($roleData['name'])->first();
|
||||
$user = User::whereHubId($userRank['hub_id'])->first();
|
||||
$user->assignRole($userRole);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the user role data based on the role name.
|
||||
*/
|
||||
protected function buildUserRoleData(string $name): array
|
||||
{
|
||||
if ($name === 'Administrator') {
|
||||
return [
|
||||
'name' => 'Administrator',
|
||||
'short_name' => 'Admin',
|
||||
'description' => 'An administrator has full access to the site.',
|
||||
'color_class' => 'sky',
|
||||
];
|
||||
}
|
||||
|
||||
if ($name === 'Moderator') {
|
||||
return [
|
||||
'name' => 'Moderator',
|
||||
'short_name' => 'Mod',
|
||||
'description' => 'A moderator has the ability to moderate user content.',
|
||||
'color_class' => 'emerald',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'short_name' => '',
|
||||
'description' => '',
|
||||
'color_class' => '',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the licenses from the Hub database to the local database.
|
||||
*/
|
||||
protected function importLicenses(): void
|
||||
{
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_license')
|
||||
->chunkById(100, function (Collection $licenses) {
|
||||
|
||||
$insertData = [];
|
||||
foreach ($licenses as $license) {
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $license->licenseID,
|
||||
'name' => $license->licenseName,
|
||||
'link' => $license->licenseURL,
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
DB::table('licenses')->upsert($insertData, ['hub_id'], ['name', 'link']);
|
||||
}
|
||||
}, 'licenseID');
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the SPT versions from the Hub database to the local database.
|
||||
*/
|
||||
protected function importSptVersions(): void
|
||||
{
|
||||
DB::connection('mysql_hub')
|
||||
->table('wcf1_label')
|
||||
->where('groupID', 1)
|
||||
->chunkById(100, function (Collection $versions) {
|
||||
$insertData = [];
|
||||
foreach ($versions as $version) {
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $version->labelID,
|
||||
'version' => $version->label,
|
||||
'color_class' => $this->translateColour($version->cssClassName),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
DB::table('spt_versions')->upsert($insertData, ['hub_id'], ['version', 'color_class']);
|
||||
}
|
||||
}, 'labelID');
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the colour class from the Hub database to the local database.
|
||||
*/
|
||||
protected function translateColour(string $colour = ''): string
|
||||
{
|
||||
return match ($colour) {
|
||||
'green' => 'green',
|
||||
'slightly-outdated' => 'lime',
|
||||
'yellow' => 'yellow',
|
||||
'red' => 'red',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the mods from the Hub database to the local database.
|
||||
*/
|
||||
protected function importMods(): void
|
||||
{
|
||||
// Initialize a cURL handler for downloading mod thumbnails.
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
|
||||
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
|
||||
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file')
|
||||
->chunkById(100, function (Collection $mods) use ($curl) {
|
||||
|
||||
foreach ($mods as $mod) {
|
||||
$modContent = DB::table('temp_file_content')
|
||||
->where('fileID', $mod->fileID)
|
||||
->orderBy('fileID', 'desc')
|
||||
->first();
|
||||
|
||||
$optionSourceCode = DB::table('temp_file_option_values')
|
||||
->select('optionValue as source_code_link')
|
||||
->where('fileID', $mod->fileID)
|
||||
->whereIn('optionID', [5, 1])
|
||||
->whereNot('optionValue', '')
|
||||
->orderByDesc('optionID')
|
||||
->first();
|
||||
|
||||
$optionContainsAi = DB::table('temp_file_option_values')
|
||||
->select('optionValue as contains_ai')
|
||||
->where('fileID', $mod->fileID)
|
||||
->where('optionID', 7)
|
||||
->whereNot('optionValue', '')
|
||||
->first();
|
||||
|
||||
$optionContainsAds = DB::table('temp_file_option_values')
|
||||
->select('optionValue as contains_ads')
|
||||
->where('fileID', $mod->fileID)
|
||||
->where('optionID', 3)
|
||||
->whereNot('optionValue', '')
|
||||
->first();
|
||||
|
||||
$versionLabel = DB::table('temp_file_version_labels')
|
||||
->select('labelID')
|
||||
->where('objectID', $mod->fileID)
|
||||
->orderBy('labelID', 'desc')
|
||||
->first();
|
||||
|
||||
// Skip the mod if it doesn't have a version label attached to it.
|
||||
if (empty($versionLabel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $mod->fileID,
|
||||
'user_id' => User::whereHubId($mod->userID)->value('id'),
|
||||
'name' => $modContent?->subject ?? '',
|
||||
'slug' => Str::slug($modContent?->subject ?? ''),
|
||||
'teaser' => Str::limit($modContent?->teaser ?? ''),
|
||||
'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 ?? '',
|
||||
'featured' => (bool) $mod?->isFeatured,
|
||||
'contains_ai_content' => (bool) $optionContainsAi?->contains_ai,
|
||||
'contains_ads' => (bool) $optionContainsAds?->contains_ads,
|
||||
'disabled' => (bool) $mod?->isDisabled,
|
||||
'created_at' => Carbon::parse($mod->time, 'UTC'),
|
||||
'updated_at' => Carbon::parse($mod->lastChangeTime, 'UTC'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
Mod::upsert($insertData, ['hub_id'], [
|
||||
'user_id',
|
||||
'name',
|
||||
'slug',
|
||||
'teaser',
|
||||
'description',
|
||||
'thumbnail',
|
||||
'license_id',
|
||||
'source_code_link',
|
||||
'featured',
|
||||
'contains_ai_content',
|
||||
'disabled',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
}, 'fileID');
|
||||
|
||||
// Close the cURL handler.
|
||||
curl_close($curl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the mod description from WoltHub flavoured HTML to Markdown.
|
||||
*/
|
||||
protected function cleanHubContent(string $dirty): string
|
||||
{
|
||||
// Alright, hear me out... Shut up.
|
||||
|
||||
$converter = new HtmlConverter();
|
||||
$clean = Purify::clean($dirty);
|
||||
|
||||
return $converter->convert($clean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the mod thumbnail from the Hub and store it anew.
|
||||
*/
|
||||
protected function fetchModThumbnail(CurlHandle $curl, 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)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Build some paths/URLs using the mod data.
|
||||
$hashShort = substr($thumbnailHash, 0, 2);
|
||||
$fileName = $fileID.'.'.$thumbnailExtension;
|
||||
$hubUrl = 'https://hub.sp-tarkov.com/files/images/file/'.$hashShort.'/'.$fileName;
|
||||
$relativePath = 'mods/'.$fileName;
|
||||
|
||||
// Determine the disk to use based on the environment.
|
||||
$disk = match (config('app.env')) {
|
||||
'production' => 'r2', // Cloudflare R2 Storage
|
||||
default => 'public', // Local
|
||||
};
|
||||
|
||||
// Check to make sure the image doesn't already exist.
|
||||
if (Storage::disk($disk)->exists($relativePath)) {
|
||||
return $relativePath; // Already exists, return the path.
|
||||
}
|
||||
|
||||
// Download the image using the cURL handler.
|
||||
curl_setopt($curl, CURLOPT_URL, $hubUrl);
|
||||
$image = curl_exec($curl);
|
||||
|
||||
if ($image === false) {
|
||||
Log::error('There was an error attempting to download a mod thumbnail. cURL error: '.curl_error($curl));
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Store the image on the disk.
|
||||
Storage::disk($disk)->put($relativePath, $image);
|
||||
|
||||
return $relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the mod versions from the Hub database to the local database.
|
||||
*/
|
||||
protected function importModVersions(): void
|
||||
{
|
||||
DB::connection('mysql_hub')
|
||||
->table('filebase1_file_version')
|
||||
->chunkById(500, function (Collection $versions) {
|
||||
|
||||
foreach ($versions as $version) {
|
||||
$versionContent = DB::table('temp_file_version_content')
|
||||
->select('description')
|
||||
->where('versionID', $version->versionID)
|
||||
->orderBy('versionID', 'desc')
|
||||
->first();
|
||||
|
||||
$optionVirusTotal = DB::table('temp_file_option_values')
|
||||
->select('optionValue as virus_total_link')
|
||||
->where('fileID', $version->fileID)
|
||||
->whereIn('optionID', [6, 2])
|
||||
->whereNot('optionValue', '')
|
||||
->orderByDesc('optionID')
|
||||
->first();
|
||||
|
||||
$versionLabel = DB::table('temp_file_version_labels')
|
||||
->select('labelID')
|
||||
->where('objectID', $version->fileID)
|
||||
->orderBy('labelID', 'desc')
|
||||
->first();
|
||||
|
||||
$modId = Mod::whereHubId($version->fileID)->value('id');
|
||||
|
||||
// Skip the mod version if it doesn't have a mod or version label attached to it.
|
||||
if (empty($versionLabel) || empty($modId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertData[] = [
|
||||
'hub_id' => (int) $version->versionID,
|
||||
'mod_id' => $modId,
|
||||
'version' => $version->versionNumber,
|
||||
'description' => $this->cleanHubContent($versionContent->description ?? ''),
|
||||
'link' => $version->downloadURL,
|
||||
'spt_version_id' => SptVersion::whereHubId($versionLabel->labelID)->value('id'),
|
||||
'virus_total_link' => $optionVirusTotal?->virus_total_link ?? '',
|
||||
'downloads' => max((int) $version->downloads, 0), // At least 0.
|
||||
'disabled' => (bool) $version->isDisabled,
|
||||
'created_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
'updated_at' => Carbon::parse($version->uploadTime, 'UTC'),
|
||||
];
|
||||
}
|
||||
|
||||
if (! empty($insertData)) {
|
||||
ModVersion::upsert($insertData, ['hub_id'], [
|
||||
'mod_id',
|
||||
'version',
|
||||
'description',
|
||||
'link',
|
||||
'spt_version_id',
|
||||
'virus_total_link',
|
||||
'downloads',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
}
|
||||
}, 'versionID');
|
||||
}
|
||||
|
||||
/**
|
||||
* The job failed to process.
|
||||
*/
|
||||
public function failed(Exception $exception): void
|
||||
{
|
||||
// Explicitly drop the temporary tables.
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_option_values');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_content');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_labels');
|
||||
DB::unprepared('DROP TEMPORARY TABLE IF EXISTS temp_file_version_content');
|
||||
|
||||
// Close the connections. This should drop the temporary tables as well, but I like to be explicit.
|
||||
DB::connection('mysql_hub')->disconnect();
|
||||
DB::disconnect();
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@ -11,9 +12,11 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Jetstream\HasProfilePhoto;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Laravel\Scout\Searchable;
|
||||
use Mchev\Banhammer\Traits\Bannable;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use Bannable;
|
||||
use HasApiTokens;
|
||||
use HasFactory;
|
||||
use HasProfilePhoto;
|
||||
@ -38,14 +41,6 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'profile_photo_url',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
|
||||
public function mods(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mod::class);
|
||||
@ -63,4 +58,24 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
return ! is_null($this->email_verified_at);
|
||||
}
|
||||
|
||||
public function assignRole(UserRole $role): bool
|
||||
{
|
||||
$this->role()->associate($role);
|
||||
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
public function role(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(UserRole::class, 'user_role_id');
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
24
app/Models/UserRole.php
Normal file
24
app/Models/UserRole.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class UserRole extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'short_name',
|
||||
'description',
|
||||
'color_class',
|
||||
];
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
}
|
54
app/Nova/UserRoleResource.php
Normal file
54
app/Nova/UserRoleResource.php
Normal file
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Nova;
|
||||
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Http\Request;
|
||||
use Laravel\Nova\Fields\ID;
|
||||
use Laravel\Nova\Fields\Text;
|
||||
|
||||
class UserRoleResource extends Resource
|
||||
{
|
||||
public static string $model = UserRole::class;
|
||||
|
||||
public static $title = 'name';
|
||||
|
||||
public static $search = [
|
||||
'id', 'name', 'description',
|
||||
];
|
||||
|
||||
public function fields(Request $request): array
|
||||
{
|
||||
return [
|
||||
ID::make()->sortable(),
|
||||
|
||||
Text::make('Name')
|
||||
->sortable()
|
||||
->rules('required'),
|
||||
|
||||
Text::make('Description')
|
||||
->sortable()
|
||||
->rules('required'),
|
||||
];
|
||||
}
|
||||
|
||||
public function cards(Request $request): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function filters(Request $request): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function lenses(Request $request): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function actions(Request $request): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal file
@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Horizon\Horizon;
|
||||
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||
|
||||
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Horizon gate.
|
||||
*
|
||||
* This gate determines who can access Horizon in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewHorizon', function ($user) {
|
||||
return in_array($user->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
@ -3,6 +3,7 @@
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Mchev\Banhammer\Middleware\IPBanned;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@ -12,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
//
|
||||
$middleware->append(IPBanned::class);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions) {
|
||||
//
|
||||
|
@ -3,6 +3,7 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
App\Providers\NovaServiceProvider::class,
|
||||
];
|
||||
|
@ -12,6 +12,7 @@
|
||||
"ext-curl": "*",
|
||||
"http-interop/http-factory-guzzle": "^1.2",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/horizon": "^5.24",
|
||||
"laravel/jetstream": "^5.1",
|
||||
"laravel/nova": "^4.0",
|
||||
"laravel/pulse": "^1.1",
|
||||
@ -21,6 +22,7 @@
|
||||
"league/flysystem-aws-s3-v3": "3.0",
|
||||
"league/html-to-markdown": "^5.1",
|
||||
"livewire/livewire": "^3.0",
|
||||
"mchev/banhammer": "^2.3",
|
||||
"meilisearch/meilisearch-php": "^1.8",
|
||||
"stevebauman/purify": "^6.2"
|
||||
},
|
||||
@ -29,6 +31,7 @@
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^2.9",
|
||||
"laravel/pint": "^1.16",
|
||||
"laravel/sail": "^1.29",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.0",
|
||||
"phpunit/phpunit": "^11.0.1",
|
||||
@ -81,6 +84,10 @@
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
},
|
||||
"platform": {
|
||||
"ext-pcntl": "8.0",
|
||||
"ext-posix": "8.0"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
|
793
composer.lock
generated
793
composer.lock
generated
File diff suppressed because it is too large
Load Diff
95
config/ban.php
Normal file
95
config/ban.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Table Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify the name of the table created during migration for the ban system.
|
||||
| This table will store information about banned users.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => 'bans',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Model Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify the model which you want to use as your Ban model.
|
||||
|
|
||||
*/
|
||||
|
||||
'model' => \Mchev\Banhammer\Models\Ban::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Where to Redirect Banned Users
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the URL to which users will be redirected when attempting to log in
|
||||
| after being banned. If not defined, the banned user will be redirected to
|
||||
| the previous page they tried to access.
|
||||
|
|
||||
*/
|
||||
|
||||
'fallback_url' => null, // Examples: null (default), "/oops", "/login"
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 403 Message
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The message that will be displayed if no fallback URL is defined for banned users.
|
||||
|
|
||||
*/
|
||||
|
||||
'messages' => [
|
||||
'user' => 'Your account has been banned.',
|
||||
'ip' => 'Access from your IP address is restricted.',
|
||||
'country' => 'Access from your country is restricted.',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Block by Country
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Determine whether to block users based on their country. This setting uses
|
||||
| the value of BANHAMMER_BLOCK_BY_COUNTRY from the environment. Enabling this
|
||||
| feature may result in up to 45 HTTP requests per minute with the free version
|
||||
| of https://ip-api.com/.
|
||||
|
|
||||
*/
|
||||
|
||||
'block_by_country' => env('BANHAMMER_BLOCK_BY_COUNTRY', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| List of Blocked Countries
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify the countries where users will be blocked if 'block_by_country' is true.
|
||||
| Add country codes to the array to restrict access from those countries.
|
||||
|
|
||||
*/
|
||||
|
||||
'blocked_countries' => [], // Examples: ['US', 'CA', 'GB', 'FR', 'ES', 'DE']
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Duration for IP Geolocation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This configuration option determines the duration, in minutes, for which
|
||||
| the IP geolocation data will be stored in the cache. This helps prevent
|
||||
| excessive requests and enables the middleware to efficiently determine
|
||||
| whether to block a request based on the user's country.
|
||||
|
|
||||
*/
|
||||
'cache_duration' => 120, // Duration in minutes
|
||||
|
||||
];
|
@ -190,7 +190,7 @@ return [
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '2'),
|
||||
'database' => env('REDIS_QUEUE_DB', '2'),
|
||||
],
|
||||
|
||||
],
|
||||
|
230
config/horizon.php
Normal file
230
config/horizon.php
Normal file
@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the subdomain where Horizon will be accessible from. If this
|
||||
| setting is null, Horizon will reside under the same domain as the
|
||||
| application. Otherwise, this value will serve as the subdomain.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('HORIZON_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the URI path where Horizon will be accessible from. Feel free
|
||||
| to change this path to anything you like. Note that the URI will not
|
||||
| affect the paths of its internal API that aren't exposed to users.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('HORIZON_PATH', 'horizon'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the name of the Redis connection where Horizon will store the
|
||||
| meta information required for it to function. It includes the list
|
||||
| of supervisors, failed jobs, job metrics, and other information.
|
||||
|
|
||||
*/
|
||||
|
||||
'use' => 'default',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Redis Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This prefix will be used when storing all Horizon data in Redis. You
|
||||
| may modify the prefix when you are running multiple installations
|
||||
| of Horizon on the same server so that they don't have problems.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env(
|
||||
'HORIZON_PREFIX',
|
||||
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Horizon Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These middleware will get attached onto each Horizon route, giving you
|
||||
| the chance to add your own middleware to this list or change any of
|
||||
| the existing middleware. Or, you can simply stick with this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Wait Time Thresholds
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to configure when the LongWaitDetected event
|
||||
| will be fired. Every connection / queue combination may have its
|
||||
| own, unique threshold (in seconds) before this event is fired.
|
||||
|
|
||||
*/
|
||||
|
||||
'waits' => [
|
||||
'redis:default' => 60,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Trimming Times
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||
| for one hour while all failed jobs are stored for an entire week.
|
||||
|
|
||||
*/
|
||||
|
||||
'trim' => [
|
||||
'recent' => 60,
|
||||
'pending' => 60,
|
||||
'completed' => 60,
|
||||
'recent_failed' => 10080,
|
||||
'failed' => 10080,
|
||||
'monitored' => 10080,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Silenced Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Silencing a job will instruct Horizon to not place the job in the list
|
||||
| of completed jobs within the Horizon dashboard. This setting may be
|
||||
| used to fully remove any noisy jobs from the completed jobs list.
|
||||
|
|
||||
*/
|
||||
|
||||
'silenced' => [
|
||||
// App\Jobs\ExampleJob::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Metrics
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you can configure how many snapshots should be kept to display in
|
||||
| the metrics graph. This will get used in combination with Horizon's
|
||||
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||
|
|
||||
*/
|
||||
|
||||
'metrics' => [
|
||||
'trim_snapshots' => [
|
||||
'job' => 24,
|
||||
'queue' => 24,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fast Termination
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When this option is enabled, Horizon's "terminate" command will not
|
||||
| wait on all of the workers to terminate unless the --wait option
|
||||
| is provided. Fast termination can shorten deployment delay by
|
||||
| allowing a new instance of Horizon to start while the last
|
||||
| instance will continue to terminate each of its workers.
|
||||
|
|
||||
*/
|
||||
|
||||
'fast_termination' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Memory Limit (MB)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value describes the maximum amount of memory the Horizon master
|
||||
| supervisor may consume before it is terminated and restarted. For
|
||||
| configuring these limits on your workers, see the next section.
|
||||
|
|
||||
*/
|
||||
|
||||
'memory_limit' => 64,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Worker Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the queue worker settings used by your application
|
||||
| in all environments. These supervisors and settings handle all your
|
||||
| queued jobs and will be provisioned by Horizon during deployment.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'balance' => 'auto',
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 128,
|
||||
'tries' => 1,
|
||||
'timeout' => 60,
|
||||
'nice' => 0,
|
||||
],
|
||||
'supervisor-long' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['long'],
|
||||
'balance' => 'auto',
|
||||
'balanceMaxShift' => 1,
|
||||
'balanceCooldown' => 3,
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
'maxTime' => 0,
|
||||
'maxJobs' => 0,
|
||||
'memory' => 256,
|
||||
'tries' => 1,
|
||||
'timeout' => 900, // 15 Minutes
|
||||
'nice' => 0,
|
||||
],
|
||||
],
|
||||
|
||||
'environments' => [
|
||||
'production' => [
|
||||
'supervisor-1' => [
|
||||
'maxProcesses' => 12,
|
||||
],
|
||||
'supervisor-long' => [
|
||||
'maxProcesses' => 4,
|
||||
],
|
||||
],
|
||||
|
||||
'local' => [
|
||||
'supervisor-1' => [],
|
||||
'supervisor-long' => [],
|
||||
],
|
||||
],
|
||||
];
|
@ -65,7 +65,7 @@ return [
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
|
@ -2,16 +2,10 @@
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Jetstream\Features;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
@ -20,9 +14,7 @@ class UserFactory extends Factory
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
* Define the user's default state.
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
@ -34,13 +26,14 @@ class UserFactory extends Factory
|
||||
'two_factor_secret' => null,
|
||||
'two_factor_recovery_codes' => null,
|
||||
'remember_token' => Str::random(10),
|
||||
'user_role_id' => null,
|
||||
'profile_photo_path' => null,
|
||||
'current_team_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
* Indicate that the user's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
@ -48,25 +41,4 @@ class UserFactory extends Factory
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the user should have a personal team.
|
||||
*/
|
||||
public function withPersonalTeam(?callable $callback = null): static
|
||||
{
|
||||
if (! Features::hasTeamFeatures()) {
|
||||
return $this->state([]);
|
||||
}
|
||||
|
||||
return $this->has(
|
||||
Team::factory()
|
||||
->state(fn (array $attributes, User $user) => [
|
||||
'name' => $user->name.'\'s Team',
|
||||
'user_id' => $user->id,
|
||||
'personal_team' => true,
|
||||
])
|
||||
->when(is_callable($callback), $callback),
|
||||
'ownedTeams'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
50
database/factories/UserRoleFactory.php
Normal file
50
database/factories/UserRoleFactory.php
Normal file
@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class UserRoleFactory extends Factory
|
||||
{
|
||||
protected $model = UserRole::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->name(),
|
||||
'short_name' => $this->faker->word(),
|
||||
'description' => $this->faker->text(),
|
||||
'color_class' => $this->faker->randomElement(['sky', 'red', 'green', 'emerald', 'lime']),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "administrator" role.
|
||||
*/
|
||||
public function administrator(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'Administrator',
|
||||
'short_name' => 'Admin',
|
||||
'description' => 'An administrator has full access to the site.',
|
||||
'color_class' => 'sky',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the "moderator" role.
|
||||
*/
|
||||
public function moderator(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'Moderator',
|
||||
'short_name' => 'Mod',
|
||||
'description' => 'A moderator has the ability to moderate user content.',
|
||||
'color_class' => 'emerald',
|
||||
]);
|
||||
}
|
||||
}
|
38
database/migrations/2024_06_10_002858_create_mod_indexes.php
Normal file
38
database/migrations/2024_06_10_002858_create_mod_indexes.php
Normal file
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('mods', function (Blueprint $table) {
|
||||
$table->index(['deleted_at', 'disabled'], 'mods_show_index');
|
||||
});
|
||||
|
||||
Schema::table('mod_versions', function (Blueprint $table) {
|
||||
$table->index(['mod_id', 'deleted_at', 'disabled', 'version'], 'mod_versions_filtering_index');
|
||||
});
|
||||
|
||||
Schema::table('spt_versions', function (Blueprint $table) {
|
||||
$table->index(['version', 'deleted_at'], 'spt_versions_filtering_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('mods', function (Blueprint $table) {
|
||||
$table->dropIndex('mods_show_index');
|
||||
$table->dropIndex('mod_versions_filtering_index');
|
||||
$table->dropIndex('spt_versions_filtering_index');
|
||||
});
|
||||
}
|
||||
};
|
35
database/migrations/2024_06_15_000000_create_bans_table.php
Normal file
35
database/migrations/2024_06_15_000000_create_bans_table.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create(config('ban.table'), function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->nullableMorphs('bannable');
|
||||
$table->nullableMorphs('created_by');
|
||||
$table->text('comment')->nullable();
|
||||
$table->string('ip', 45)->nullable();
|
||||
$table->timestamp('expired_at')->nullable();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
$table->index('ip');
|
||||
$table->index('expired_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists(config('ban.table'));
|
||||
}
|
||||
};
|
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn(config('ban.table'), 'metas')) {
|
||||
Schema::table(config('ban.table'), function (Blueprint $table) {
|
||||
$table->json('metas')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn(config('ban.table'), 'metas')) {
|
||||
Schema::table(config('ban.table'), function (Blueprint $table) {
|
||||
$table->dropColumn('metas');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('user_roles', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('short_name')->default('');
|
||||
$table->string('description')->default('');
|
||||
$table->string('color_class')->default('');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('user_roles');
|
||||
}
|
||||
};
|
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->foreignIdFor(UserRole::class)
|
||||
->nullable()
|
||||
->after('remember_token')
|
||||
->constrained()
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['user_role_id']);
|
||||
});
|
||||
}
|
||||
};
|
@ -7,6 +7,7 @@ use App\Models\Mod;
|
||||
use App\Models\ModVersion;
|
||||
use App\Models\SptVersion;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRole;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
@ -22,6 +23,14 @@ class DatabaseSeeder extends Seeder
|
||||
// Create some code licenses.
|
||||
$licenses = License::factory(10)->create();
|
||||
|
||||
// Add 5 administrators.
|
||||
$administrator = UserRole::factory()->administrator()->create();
|
||||
User::factory(5)->create(['user_role_id' => $administrator->id]);
|
||||
|
||||
// Add 10 moderators.
|
||||
$moderator = UserRole::factory()->moderator()->create();
|
||||
User::factory(10)->create(['user_role_id' => $moderator->id]);
|
||||
|
||||
// Add 100 users.
|
||||
$users = User::factory(100)->create();
|
||||
|
||||
|
103
docker-compose.yml
Normal file
103
docker-compose.yml
Normal file
@ -0,0 +1,103 @@
|
||||
services:
|
||||
laravel.test:
|
||||
build:
|
||||
context: ./vendor/laravel/sail/runtimes/8.3
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
image: sail-8.3/app
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
ports:
|
||||
- '${APP_PORT:-80}:80'
|
||||
- '${VITE_PORT:-5173}:${VITE_PORT:-5173}'
|
||||
environment:
|
||||
WWWUSER: '${WWWUSER}'
|
||||
LARAVEL_SAIL: 1
|
||||
XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}'
|
||||
XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}'
|
||||
IGNITION_LOCAL_SITES_PATH: '${PWD}'
|
||||
volumes:
|
||||
- '.:/var/www/html'
|
||||
networks:
|
||||
- sail
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
- meilisearch
|
||||
- mailpit
|
||||
mysql:
|
||||
image: 'mysql/mysql-server:8.0'
|
||||
ports:
|
||||
- '${FORWARD_DB_PORT:-3306}:3306'
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ROOT_HOST: '%'
|
||||
MYSQL_DATABASE: '${DB_DATABASE}'
|
||||
MYSQL_USER: '${DB_USERNAME}'
|
||||
MYSQL_PASSWORD: '${DB_PASSWORD}'
|
||||
MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||
volumes:
|
||||
- 'sail-mysql:/var/lib/mysql'
|
||||
- './vendor/laravel/sail/database/mysql/create-testing-database.sh:/docker-entrypoint-initdb.d/10-create-testing-database.sh'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- mysqladmin
|
||||
- ping
|
||||
- '-p${DB_PASSWORD}'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
ports:
|
||||
- '${FORWARD_REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- 'sail-redis:/data'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- redis-cli
|
||||
- ping
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
meilisearch:
|
||||
image: 'getmeili/meilisearch:latest'
|
||||
ports:
|
||||
- '${FORWARD_MEILISEARCH_PORT:-7700}:7700'
|
||||
environment:
|
||||
MEILI_NO_ANALYTICS: '${MEILISEARCH_NO_ANALYTICS:-false}'
|
||||
volumes:
|
||||
- 'sail-meilisearch:/meili_data'
|
||||
networks:
|
||||
- sail
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- wget
|
||||
- '--no-verbose'
|
||||
- '--spider'
|
||||
- 'http://localhost:7700/health'
|
||||
retries: 3
|
||||
timeout: 5s
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
ports:
|
||||
- '${FORWARD_MAILPIT_PORT:-1025}:1025'
|
||||
- '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025'
|
||||
networks:
|
||||
- sail
|
||||
networks:
|
||||
sail:
|
||||
driver: bridge
|
||||
volumes:
|
||||
sail-mysql:
|
||||
driver: local
|
||||
sail-redis:
|
||||
driver: local
|
||||
sail-meilisearch:
|
||||
driver: local
|
@ -22,7 +22,7 @@
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_DATABASE" value="test"/>
|
||||
<env name="DB_DATABASE" value="testing"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
|
2
public/vendor/nova/app.js
vendored
2
public/vendor/nova/app.js
vendored
File diff suppressed because one or more lines are too long
2
public/vendor/nova/app.js.map
vendored
2
public/vendor/nova/app.js.map
vendored
File diff suppressed because one or more lines are too long
2
public/vendor/nova/mix-manifest.json
vendored
2
public/vendor/nova/mix-manifest.json
vendored
@ -1,5 +1,5 @@
|
||||
{
|
||||
"/app.js": "/app.js?id=d5c07e2eeefdadf9650a9473ea340c89",
|
||||
"/app.js": "/app.js?id=4f1ec3789b86ed89a9a1900e986b95e6",
|
||||
"/manifest.js": "/manifest.js?id=d6d76d12b7219df564489d400c711198",
|
||||
"/app.css": "/app.css?id=3d962b859bf103c1663adb9513497f17",
|
||||
"/vendor.js": "/vendor.js?id=0b026297072f6c8be97d0c900a2d4770",
|
||||
|
@ -10,7 +10,7 @@
|
||||
<img src="https://placehold.co/450x450/EEE/31343C?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="block dark:hidden h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
|
||||
<img src="https://placehold.co/450x450/31343C/EEE?font=source-sans-pro&text={{ $mod->name }}" alt="{{ $mod->name }}" class="hidden dark:block h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
|
||||
@else
|
||||
<img src="{{ Storage::url($mod->thumbnail) }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
|
||||
<img src="{{ asset($mod->thumbnail) }}" alt="{{ $mod->name }}" class="h-48 w-full object-cover md:h-full md:w-48 transform group-hover:scale-110 transition-all duration-200">
|
||||
@endif
|
||||
</div>
|
||||
<div class="flex flex-col w-full justify-between p-5">
|
||||
|
@ -11,6 +11,8 @@
|
||||
<link href="//fonts.bunny.net" rel="preconnect">
|
||||
<link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet">
|
||||
|
||||
<link href="{{ config('app.asset_url') }}" rel="dns-prefetch">
|
||||
|
||||
<script>
|
||||
// Immediately set the theme to prevent a flash of the default theme when another is set.
|
||||
// Must be located inline, in the head, and before any CSS is loaded.
|
||||
|
@ -12,7 +12,14 @@
|
||||
|
||||
{{-- Navigation Links --}}
|
||||
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
|
||||
{{-- <x-nav-link href="{{ route('mods') }}" :active="request()->routeIs('mods')">{{ __('Mods') }}</x-nav-link> --}}
|
||||
@auth
|
||||
<x-nav-link href="#">{{ __('Dashboard') }}</x-nav-link>
|
||||
@endauth
|
||||
<x-nav-link href="#">{{ __('About') }}</x-nav-link>
|
||||
<x-nav-link href="#">{{ __('Articles') }}</x-nav-link>
|
||||
<x-nav-link href="#">{{ __('Documentation') }}</x-nav-link>
|
||||
<x-nav-link href="{{ route('mods') }}" :active="request()->routeIs('mods')">{{ __('Mods') }}</x-nav-link>
|
||||
<x-nav-link href="#">{{ __('Support') }}</x-nav-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -35,7 +42,7 @@
|
||||
|
||||
@guest
|
||||
<div class="ml-4">
|
||||
<a href="{{ route('login') }}" class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100">Log in
|
||||
<a href="{{ route('login') }}" class="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100 whitespace-nowrap">Log in
|
||||
<span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
@endguest
|
||||
@ -100,14 +107,14 @@
|
||||
{{-- Responsive Navigation Menu --}}
|
||||
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
|
||||
<div class="pt-2 pb-3 space-y-1">
|
||||
<x-responsive-nav-link href="{{ route('home') }}" :active="request()->routeIs('home')">
|
||||
{{ __('Home') }}
|
||||
</x-responsive-nav-link>
|
||||
@auth()
|
||||
<x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">
|
||||
{{ __('Dashboard') }}
|
||||
</x-responsive-nav-link>
|
||||
<x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">{{ __('Dashboard') }}</x-responsive-nav-link>
|
||||
@endauth
|
||||
<x-responsive-nav-link href="#">{{ __('About') }}</x-responsive-nav-link>
|
||||
<x-responsive-nav-link href="#">{{ __('Articles') }}</x-responsive-nav-link>
|
||||
<x-responsive-nav-link href="#">{{ __('Documentation') }}</x-responsive-nav-link>
|
||||
<x-responsive-nav-link href="{{ route('mods') }}" :active="request()->routeIs('mods')">{{ __('Mods') }}</x-responsive-nav-link>
|
||||
<x-responsive-nav-link href="#">{{ __('Support') }}</x-responsive-nav-link>
|
||||
</div>
|
||||
{{-- Responsive Settings Options --}}
|
||||
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700">
|
||||
|
@ -3,17 +3,21 @@
|
||||
use App\Http\Controllers\ModController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', function () {
|
||||
return view('home');
|
||||
})->name('home');
|
||||
Route::middleware(['auth.banned'])->group(function () {
|
||||
|
||||
Route::controller(ModController::class)->group(function () {
|
||||
Route::get('/mods', 'index')->name('mods');
|
||||
Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show');
|
||||
});
|
||||
Route::get('/', function () {
|
||||
return view('home');
|
||||
})->name('home');
|
||||
|
||||
Route::controller(ModController::class)->group(function () {
|
||||
Route::get('/mods', 'index')->name('mods');
|
||||
Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
})->name('dashboard');
|
||||
});
|
||||
|
||||
Route::middleware(['auth:sanctum', config('jetstream.auth_session'), 'verified'])->group(function () {
|
||||
Route::get('/dashboard', function () {
|
||||
return view('dashboard');
|
||||
})->name('dashboard');
|
||||
});
|
||||
|
@ -20,7 +20,7 @@ class ApiTokenPermissionsTest extends TestCase
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
|
@ -19,7 +19,7 @@ class CreateApiTokenTest extends TestCase
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
Livewire::test(ApiTokenManager::class)
|
||||
->set(['createApiTokenForm' => [
|
||||
|
@ -20,7 +20,7 @@ class DeleteApiTokenTest extends TestCase
|
||||
$this->markTestSkipped('API support is not enabled.');
|
||||
}
|
||||
|
||||
$this->actingAs($user = User::factory()->withPersonalTeam()->create());
|
||||
$this->actingAs($user = User::factory()->create());
|
||||
|
||||
$token = $user->tokens()->create([
|
||||
'name' => 'Test Token',
|
||||
|
@ -20,7 +20,7 @@ class EmailVerificationTest extends TestCase
|
||||
$this->markTestSkipped('Email verification not enabled.');
|
||||
}
|
||||
|
||||
$user = User::factory()->withPersonalTeam()->unverified()->create();
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/email/verify');
|
||||
|
||||
|
@ -12,7 +12,7 @@ class PasswordConfirmationTest extends TestCase
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->withPersonalTeam()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/user/confirm-password');
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user