Merge branch 'develop'

This commit is contained in:
Refringe 2024-06-18 22:07:30 -04:00
commit 9ab747b19e
Signed by: Refringe
SSH Key Fingerprint: SHA256:t865XsQpfTeqPRBMN2G6+N8wlDjkgUCZF3WGW6O9N/k
50 changed files with 2765 additions and 1065 deletions

10
.env.ci
View File

@ -11,13 +11,13 @@ LOG_LEVEL=debug
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=33306 DB_PORT=33306
DB_DATABASE=test DB_DATABASE=testing
DB_USERNAME=root DB_USERNAME=user
DB_PASSWORD= DB_PASSWORD=password
BROADCAST_DRIVER=log SCOUT_DRIVER=null
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
CACHE_DRIVER=array CACHE_STORE=array
EMAIL_DRIVER=array EMAIL_DRIVER=array
SESSION_DRIVER=array SESSION_DRIVER=array

View File

@ -3,7 +3,7 @@ APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_TIMEZONE=UTC APP_TIMEZONE=UTC
APP_URL=http://forge.test APP_URL=http://localhost
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
@ -18,21 +18,22 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# Due to the hub import script, only MySQL is supported at this time.
DB_CONNECTION=mysql DB_CONNECTION=mysql
DB_HOST=localhost DB_HOST=mysql
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=forge DB_DATABASE=forge
DB_USERNAME=root DB_USERNAME=forge
DB_PASSWORD= DB_PASSWORD=password
# This is only needed if you are running the app:import-hub command. # 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: # For normal development you should just seed the database with fake data:
# `php artisan migrate:fresh --seed` # `php artisan migrate:fresh --seed`
DB_HUB_CONNECTION=mysql DB_HUB_CONNECTION=mysql
DB_HUB_HOST=localhost DB_HUB_HOST=
DB_HUB_PORT=3306 DB_HUB_PORT=
DB_HUB_DATABASE=forge DB_HUB_DATABASE=
DB_HUB_USERNAME=root DB_HUB_USERNAME=
DB_HUB_PASSWORD= DB_HUB_PASSWORD=
SESSION_DRIVER=redis SESSION_DRIVER=redis
@ -43,32 +44,32 @@ SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
ASSET_URL=http://localhost/storage
CACHE_STORE=redis CACHE_STORE=redis
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1 REDIS_HOST=redis
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_QUEUE=default
REDIS_CACHE_CONNECTION=cache REDIS_CACHE_CONNECTION=cache
REDIS_QUEUE_CONNECTION=queue
REDIS_QUEUE=queue
SCOUT_DRIVER=meilisearch
SCOUT_QUEUE=true SCOUT_QUEUE=true
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700 MEILISEARCH_HOST=http://meilisearch:7700
MEILISEARCH_KEY=LARAVEL-HERD MEILISEARCH_KEY=
MEILISEARCH_NO_ANALYTICS=true MEILISEARCH_NO_ANALYTICS=true
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=127.0.0.1 MAIL_HOST=mailpit
MAIL_PORT=2525 MAIL_PORT=1025
MAIL_USERNAME=${APP_NAME} MAIL_USERNAME=${APP_NAME}
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com" MAIL_FROM_ADDRESS="no-reply@sp-tarkov.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
NOVA_LICENSE_KEY= NOVA_LICENSE_KEY=
SAIL_XDEBUG_MODE=develop,debug,coverage

97
.github/README.md vendored Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -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

View File

@ -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
View 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'

View File

@ -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
View 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

View File

@ -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
View File

@ -17,3 +17,4 @@ yarn-error.log
/.fleet /.fleet
/.idea /.idea
/.vscode /.vscode
.DS_Store

373
LICENSE Normal file
View 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.

View File

@ -1 +0,0 @@
# The Forge

View File

@ -2,20 +2,8 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\License; use App\Jobs\ImportHubData;
use App\Models\Mod;
use App\Models\ModVersion;
use App\Models\SptVersion;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command; 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 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.'; protected $description = 'Connects to the Hub database and imports the data into the Laravel database.';
/**
* Execute the console command.
*/
public function handle(): void public function handle(): void
{ {
// This may take a minute or two... // Add the ImportHubData job to the queue.
set_time_limit(0); ImportHubData::dispatch()->onQueue('long');
$this->newLine(); $this->info('The import job has been added to the queue.');
$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));
} }
} }

View File

@ -8,24 +8,15 @@ use Illuminate\Support\Facades\Storage;
class UploadAssets extends Command class UploadAssets extends Command
{ {
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:upload-assets'; protected $signature = 'app:upload-assets';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Uploads the Vite build assets to Cloudflare R2'; 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...'); $this->info('Publishing assets...');

678
app/Jobs/ImportHubData.php Normal file
View 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();
}
}

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -11,9 +12,11 @@ use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
use Mchev\Banhammer\Traits\Bannable;
class User extends Authenticatable implements MustVerifyEmail class User extends Authenticatable implements MustVerifyEmail
{ {
use Bannable;
use HasApiTokens; use HasApiTokens;
use HasFactory; use HasFactory;
use HasProfilePhoto; use HasProfilePhoto;
@ -38,14 +41,6 @@ class User extends Authenticatable implements MustVerifyEmail
'profile_photo_url', 'profile_photo_url',
]; ];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
public function mods(): HasMany public function mods(): HasMany
{ {
return $this->hasMany(Mod::class); return $this->hasMany(Mod::class);
@ -63,4 +58,24 @@ class User extends Authenticatable implements MustVerifyEmail
{ {
return ! is_null($this->email_verified_at); 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
View 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);
}
}

View 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 [];
}
}

View 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, [
//
]);
});
}
}

View File

@ -3,6 +3,7 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Mchev\Banhammer\Middleware\IPBanned;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@ -12,7 +13,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
// $middleware->append(IPBanned::class);
}) })
->withExceptions(function (Exceptions $exceptions) { ->withExceptions(function (Exceptions $exceptions) {
// //

View File

@ -3,6 +3,7 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class, App\Providers\FortifyServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\JetstreamServiceProvider::class, App\Providers\JetstreamServiceProvider::class,
App\Providers\NovaServiceProvider::class, App\Providers\NovaServiceProvider::class,
]; ];

View File

@ -12,6 +12,7 @@
"ext-curl": "*", "ext-curl": "*",
"http-interop/http-factory-guzzle": "^1.2", "http-interop/http-factory-guzzle": "^1.2",
"laravel/framework": "^11.0", "laravel/framework": "^11.0",
"laravel/horizon": "^5.24",
"laravel/jetstream": "^5.1", "laravel/jetstream": "^5.1",
"laravel/nova": "^4.0", "laravel/nova": "^4.0",
"laravel/pulse": "^1.1", "laravel/pulse": "^1.1",
@ -21,6 +22,7 @@
"league/flysystem-aws-s3-v3": "3.0", "league/flysystem-aws-s3-v3": "3.0",
"league/html-to-markdown": "^5.1", "league/html-to-markdown": "^5.1",
"livewire/livewire": "^3.0", "livewire/livewire": "^3.0",
"mchev/banhammer": "^2.3",
"meilisearch/meilisearch-php": "^1.8", "meilisearch/meilisearch-php": "^1.8",
"stevebauman/purify": "^6.2" "stevebauman/purify": "^6.2"
}, },
@ -29,6 +31,7 @@
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"larastan/larastan": "^2.9", "larastan/larastan": "^2.9",
"laravel/pint": "^1.16", "laravel/pint": "^1.16",
"laravel/sail": "^1.29",
"mockery/mockery": "^1.6", "mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0", "nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0.1", "phpunit/phpunit": "^11.0.1",
@ -81,6 +84,10 @@
"allow-plugins": { "allow-plugins": {
"pestphp/pest-plugin": true, "pestphp/pest-plugin": true,
"php-http/discovery": true "php-http/discovery": true
},
"platform": {
"ext-pcntl": "8.0",
"ext-posix": "8.0"
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",

793
composer.lock generated

File diff suppressed because it is too large Load Diff

95
config/ban.php Normal file
View 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
];

View File

@ -190,7 +190,7 @@ return [
'username' => env('REDIS_USERNAME'), 'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'), 'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'), 'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '2'), 'database' => env('REDIS_QUEUE_DB', '2'),
], ],
], ],

230
config/horizon.php Normal file
View 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' => [],
],
],
];

View File

@ -65,7 +65,7 @@ return [
'redis' => [ 'redis' => [
'driver' => 'redis', 'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'connection' => env('REDIS_QUEUE_CONNECTION', 'queue'),
'queue' => env('REDIS_QUEUE', 'default'), 'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => null, 'block_for' => null,

View File

@ -2,16 +2,10 @@
namespace Database\Factories; namespace Database\Factories;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Jetstream\Features;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory class UserFactory extends Factory
{ {
/** /**
@ -20,9 +14,7 @@ class UserFactory extends Factory
protected static ?string $password; protected static ?string $password;
/** /**
* Define the model's default state. * Define the user's default state.
*
* @return array<string, mixed>
*/ */
public function definition(): array public function definition(): array
{ {
@ -34,13 +26,14 @@ class UserFactory extends Factory
'two_factor_secret' => null, 'two_factor_secret' => null,
'two_factor_recovery_codes' => null, 'two_factor_recovery_codes' => null,
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'user_role_id' => null,
'profile_photo_path' => null, 'profile_photo_path' => null,
'current_team_id' => 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 public function unverified(): static
{ {
@ -48,25 +41,4 @@ class UserFactory extends Factory
'email_verified_at' => null, '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'
);
}
} }

View 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',
]);
}
}

View 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');
});
}
};

View 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'));
}
};

View File

@ -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');
});
}
}
};

View File

@ -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');
}
};

View File

@ -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']);
});
}
};

View File

@ -7,6 +7,7 @@ use App\Models\Mod;
use App\Models\ModVersion; use App\Models\ModVersion;
use App\Models\SptVersion; use App\Models\SptVersion;
use App\Models\User; use App\Models\User;
use App\Models\UserRole;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@ -22,6 +23,14 @@ class DatabaseSeeder extends Seeder
// Create some code licenses. // Create some code licenses.
$licenses = License::factory(10)->create(); $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. // Add 100 users.
$users = User::factory(100)->create(); $users = User::factory(100)->create();

103
docker-compose.yml Normal file
View 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

View File

@ -22,7 +22,7 @@
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="CACHE_STORE" value="array"/> <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="MAIL_MAILER" value="array"/>
<env name="PULSE_ENABLED" value="false"/> <env name="PULSE_ENABLED" value="false"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{ {
"/app.js": "/app.js?id=d5c07e2eeefdadf9650a9473ea340c89", "/app.js": "/app.js?id=4f1ec3789b86ed89a9a1900e986b95e6",
"/manifest.js": "/manifest.js?id=d6d76d12b7219df564489d400c711198", "/manifest.js": "/manifest.js?id=d6d76d12b7219df564489d400c711198",
"/app.css": "/app.css?id=3d962b859bf103c1663adb9513497f17", "/app.css": "/app.css?id=3d962b859bf103c1663adb9513497f17",
"/vendor.js": "/vendor.js?id=0b026297072f6c8be97d0c900a2d4770", "/vendor.js": "/vendor.js?id=0b026297072f6c8be97d0c900a2d4770",

View File

@ -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/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"> <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 @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 @endif
</div> </div>
<div class="flex flex-col w-full justify-between p-5"> <div class="flex flex-col w-full justify-between p-5">

View File

@ -11,6 +11,8 @@
<link href="//fonts.bunny.net" rel="preconnect"> <link href="//fonts.bunny.net" rel="preconnect">
<link href="//fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet"> <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> <script>
// Immediately set the theme to prevent a flash of the default theme when another is set. // 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. // Must be located inline, in the head, and before any CSS is loaded.

View File

@ -12,7 +12,14 @@
{{-- Navigation Links --}} {{-- Navigation Links --}}
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex"> <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>
</div> </div>
@ -35,7 +42,7 @@
@guest @guest
<div class="ml-4"> <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">&rarr;</span></a> <span aria-hidden="true">&rarr;</span></a>
</div> </div>
@endguest @endguest
@ -100,14 +107,14 @@
{{-- Responsive Navigation Menu --}} {{-- Responsive Navigation Menu --}}
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden"> <div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1"> <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() @auth()
<x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')"> <x-responsive-nav-link href="{{ route('dashboard') }}" :active="request()->routeIs('dashboard')">{{ __('Dashboard') }}</x-responsive-nav-link>
{{ __('Dashboard') }}
</x-responsive-nav-link>
@endauth @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> </div>
{{-- Responsive Settings Options --}} {{-- Responsive Settings Options --}}
<div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700"> <div class="pt-4 pb-1 border-t border-gray-200 dark:border-gray-700">

View File

@ -3,17 +3,21 @@
use App\Http\Controllers\ModController; use App\Http\Controllers\ModController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', function () { Route::middleware(['auth.banned'])->group(function () {
return view('home');
})->name('home');
Route::controller(ModController::class)->group(function () { Route::get('/', function () {
Route::get('/mods', 'index')->name('mods'); return view('home');
Route::get('/mod/{mod}/{slug}', 'show')->where(['id' => '[0-9]+'])->name('mod.show'); })->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');
}); });

View File

@ -20,7 +20,7 @@ class ApiTokenPermissionsTest extends TestCase
$this->markTestSkipped('API support is not enabled.'); $this->markTestSkipped('API support is not enabled.');
} }
$this->actingAs($user = User::factory()->withPersonalTeam()->create()); $this->actingAs($user = User::factory()->create());
$token = $user->tokens()->create([ $token = $user->tokens()->create([
'name' => 'Test Token', 'name' => 'Test Token',

View File

@ -19,7 +19,7 @@ class CreateApiTokenTest extends TestCase
$this->markTestSkipped('API support is not enabled.'); $this->markTestSkipped('API support is not enabled.');
} }
$this->actingAs($user = User::factory()->withPersonalTeam()->create()); $this->actingAs($user = User::factory()->create());
Livewire::test(ApiTokenManager::class) Livewire::test(ApiTokenManager::class)
->set(['createApiTokenForm' => [ ->set(['createApiTokenForm' => [

View File

@ -20,7 +20,7 @@ class DeleteApiTokenTest extends TestCase
$this->markTestSkipped('API support is not enabled.'); $this->markTestSkipped('API support is not enabled.');
} }
$this->actingAs($user = User::factory()->withPersonalTeam()->create()); $this->actingAs($user = User::factory()->create());
$token = $user->tokens()->create([ $token = $user->tokens()->create([
'name' => 'Test Token', 'name' => 'Test Token',

View File

@ -20,7 +20,7 @@ class EmailVerificationTest extends TestCase
$this->markTestSkipped('Email verification not enabled.'); $this->markTestSkipped('Email verification not enabled.');
} }
$user = User::factory()->withPersonalTeam()->unverified()->create(); $user = User::factory()->unverified()->create();
$response = $this->actingAs($user)->get('/email/verify'); $response = $this->actingAs($user)->get('/email/verify');

View File

@ -12,7 +12,7 @@ class PasswordConfirmationTest extends TestCase
public function test_confirm_password_screen_can_be_rendered(): void 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'); $response = $this->actingAs($user)->get('/user/confirm-password');