Initialer Laravel Commit für BetiX
This commit is contained in:
32
.claude/settings.local.json
Normal file
32
.claude/settings.local.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(xargs grep:*)",
|
||||||
|
"Bash(grep -r \"Error.*Handler\\\\|Exception.*Handler\" /c/Users/Dolo/Documents/Herd/casino/app --include=*.php)",
|
||||||
|
"Bash(grep -r \"Email.*Confirm\\\\|Withdraw.*2FA\\\\|cooldown\" /c/Users/Dolo/Documents/Herd/casino/app --include=*.php)",
|
||||||
|
"Bash(grep -r \"transition\\\\|animation\\\\|fade\" /c/Users/Dolo/Documents/Herd/casino/resources/js/components --include=*.vue)",
|
||||||
|
"Bash(grep -r \"glassmorphism\\\\|neon\\\\|glass\" /c/Users/Dolo/Documents/Herd/casino/resources --include=*.vue --include=*.css)",
|
||||||
|
"Bash(grep -r \"dark\\\\|light\\\\|theme\" /c/Users/Dolo/Documents/Herd/casino/resources/js --include=*.ts --include=*.vue)",
|
||||||
|
"Bash(grep -r \"2FA\\\\|Email.*Verif\" /c/Users/Dolo/Documents/Herd/casino/app/Http/Controllers --include=*.php)",
|
||||||
|
"Bash(grep -r withdraw /c/Users/Dolo/Documents/Herd/casino/app --include=*.php -i)",
|
||||||
|
"Bash(grep -r \"class.*Vip\\\\|function.*vip\" /c/Users/Dolo/Documents/Herd/casino/app --include=*.php)",
|
||||||
|
"Bash(grep -r \"slot\\\\|game\\\\|layout\" /c/Users/Dolo/Documents/Herd/casino/resources/js/components --include=*.vue)",
|
||||||
|
"Bash(grep -rn lucide C:UsersDoloDocumentsHerdcasinoresources --include=*.ts --include=*.js -l)",
|
||||||
|
"Bash(grep -n \"AdminLayout\" \"/c/Users/Dolo/Documents/Herd/casino/resources/js/pages/Admin/\"*.vue)",
|
||||||
|
"Bash(php artisan:*)",
|
||||||
|
"Bash(/c/Users/Dolo/AppData/Roaming/Herd/config/php/php.ini)",
|
||||||
|
"Bash(grep -rn \"from ''@/layouts/user/userlayout\" \"/c/Users/Dolo/Documents/Herd/casino/resources/js/pages/Admin/\")",
|
||||||
|
"Bash(grep -rn support /c/Users/Dolo/Documents/Herd/casino/routes/ --include=*.php)",
|
||||||
|
"Bash(find /c/Users/Dolo/Documents/Herd/casino/routes -name *.php -exec cat {})",
|
||||||
|
"Bash(2)",
|
||||||
|
"Bash(xargs cat:*)",
|
||||||
|
"Bash(/c/Users/Dolo/AppData/Local/herd-lite/bin/php.bat artisan:*)",
|
||||||
|
"Bash(/c/Users/Dolo/.config/herd/bin/php84/php.exe artisan:*)",
|
||||||
|
"Bash(find /c/Users/Dolo/Documents/Herd/casino -type d -name game* -o -name slot* -o -name pages)",
|
||||||
|
"Bash(grep -r \"originals/launch\\\\|launchOriginal\" /c/Users/Dolo/Documents/Herd/casino --include=*.php --include=*.ts --include=*.vue)",
|
||||||
|
"Bash(xargs wc:*)",
|
||||||
|
"Bash(\"/c/Users/Dolo/AppData/Local/Herd/resources/app.asar.unpacked/resources/win/bin/php/php\" artisan:*)",
|
||||||
|
"Bash(herd php:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
18
.editorconfig
Normal file
18
.editorconfig
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 4
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
69
.env.example
Normal file
69
.env.example
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://deine-domain.com
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=laravel
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
# --- NOWPayments LIVE Konfiguration ---
|
||||||
|
NOWPAYMENTS_ENV=live
|
||||||
|
NOWPAYMENTS_BASE_URL=https://api.nowpayments.io/v1
|
||||||
|
NOWPAYMENTS_API_KEY=dein_live_api_key_hier
|
||||||
|
NOWPAYMENTS_PUBLIC_KEY=dein_oeffentlicher_api_key_hier
|
||||||
|
NOWPAYMENTS_IPN_SECRET=dein_ipn_secret_hier
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
README.md export-ignore
|
||||||
|
.github/workflows/browser-tests.yml export-ignore
|
||||||
49
.github/workflows/lint.yml
vendored
Normal file
49
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- workos
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- workos
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run Pint
|
||||||
|
run: composer lint
|
||||||
|
|
||||||
|
- name: Format Frontend
|
||||||
|
run: npm run format
|
||||||
|
|
||||||
|
- name: Lint Frontend
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
# - name: Commit Changes
|
||||||
|
# uses: stefanzweifel/git-auto-commit-action@v7
|
||||||
|
# with:
|
||||||
|
# commit_message: fix code style
|
||||||
|
# commit_options: '--no-verify'
|
||||||
56
.github/workflows/tests.yml
vendored
Normal file
56
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- workos
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- develop
|
||||||
|
- main
|
||||||
|
- master
|
||||||
|
- workos
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
php-version: ['8.4', '8.5']
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: ${{ matrix.php-version }}
|
||||||
|
tools: composer:v2
|
||||||
|
coverage: xdebug
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install Node Dependencies
|
||||||
|
run: npm i
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
|
- name: Copy Environment File
|
||||||
|
run: cp .env.example .env
|
||||||
|
|
||||||
|
- name: Generate Application Key
|
||||||
|
run: php artisan key:generate
|
||||||
|
|
||||||
|
- name: Build Assets
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: ./vendor/bin/pest
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/.phpunit.cache
|
||||||
|
/bootstrap/ssr
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/resources/js/actions
|
||||||
|
/resources/js/routes
|
||||||
|
/resources/js/wayfinder
|
||||||
|
/vendor
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
.env.backup
|
||||||
|
.env.production
|
||||||
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
/auth.json
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
resources/js/components/ui/*
|
||||||
|
resources/views/mail/*
|
||||||
25
.prettierrc
Normal file
25
.prettierrc
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"singleAttributePerLine": false,
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"printWidth": 80,
|
||||||
|
"plugins": [
|
||||||
|
"prettier-plugin-tailwindcss"
|
||||||
|
],
|
||||||
|
"tailwindFunctions": [
|
||||||
|
"clsx",
|
||||||
|
"cn",
|
||||||
|
"cva"
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "resources/css/app.css",
|
||||||
|
"tabWidth": 4,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "**/*.yml",
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
85
app/Actions/Fortify/CreateNewUser.php
Normal file
85
app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
|
class CreateNewUser implements CreatesNewUsers
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and create a newly registered user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function create(array $input)
|
||||||
|
{
|
||||||
|
// Check if registration is enabled via admin site settings
|
||||||
|
$siteSettings = \App\Models\AppSetting::get('site.settings', []);
|
||||||
|
if (isset($siteSettings['registration_open']) && $siteSettings['registration_open'] === false) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['Die Registrierung ist derzeit deaktiviert.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Validator::make($input, [
|
||||||
|
'username' => ['required', 'string', 'max:255', 'alpha_dash', Rule::unique(User::class)],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
|
||||||
|
'first_name' => ['required', 'string', 'max:255'],
|
||||||
|
'last_name' => ['required', 'string', 'max:255'],
|
||||||
|
'birthdate' => ['required', 'date', 'before:today'],
|
||||||
|
'gender' => ['required', 'string', Rule::in(['male', 'female', 'other'])],
|
||||||
|
'phone' => ['required', 'string', 'max:255'],
|
||||||
|
'country' => ['required', 'string', 'size:2'],
|
||||||
|
'address_line1'=> ['required', 'string', 'max:255'],
|
||||||
|
'address_line2'=> ['nullable', 'string', 'max:255'],
|
||||||
|
'city' => ['required', 'string', 'max:255'],
|
||||||
|
'postal_code' => ['required', 'string', 'max:255'],
|
||||||
|
'currency' => ['required', 'string', Rule::in(['EUR', 'USD', 'GBP', 'BTC'])],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
'is_adult' => ['accepted'],
|
||||||
|
'terms_accepted' => ['accepted'],
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
// Anti-Abuse: block if more than 3 accounts already registered from this IP in 24h
|
||||||
|
$ip = request()->ip();
|
||||||
|
if ($ip) {
|
||||||
|
$recentCount = User::where('registration_ip', $ip)
|
||||||
|
->where('created_at', '>=', now()->subHours(24))
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($recentCount >= 3) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'email' => ['Too many accounts registered from this IP address. Please try again later.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return User::create([
|
||||||
|
'username' => $input['username'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'first_name' => $input['first_name'],
|
||||||
|
'last_name' => $input['last_name'],
|
||||||
|
'name' => ($input['first_name'] ?? '') . ' ' . ($input['last_name'] ?? ''),
|
||||||
|
'birthdate' => $input['birthdate'],
|
||||||
|
'gender' => $input['gender'],
|
||||||
|
'phone' => $input['phone'],
|
||||||
|
'country' => $input['country'],
|
||||||
|
'address_line1' => $input['address_line1'],
|
||||||
|
'address_line2' => $input['address_line2'] ?? '',
|
||||||
|
'city' => $input['city'],
|
||||||
|
'postal_code' => $input['postal_code'],
|
||||||
|
'currency' => $input['currency'],
|
||||||
|
'is_adult' => (bool)$input['is_adult'],
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
'registration_ip' => $ip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and reset the user's forgotten password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function reset(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => $input['password'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Auth/EncryptedUserProvider.php
Normal file
98
app/Auth/EncryptedUserProvider.php
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Auth;
|
||||||
|
|
||||||
|
use Illuminate\Auth\EloquentUserProvider;
|
||||||
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class EncryptedUserProvider extends EloquentUserProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Retrieve a user by the given credentials.
|
||||||
|
*
|
||||||
|
* @param array $credentials
|
||||||
|
* @return \Illuminate\Contracts\Auth\Authenticatable|null
|
||||||
|
*/
|
||||||
|
public function retrieveByCredentials(array $credentials)
|
||||||
|
{
|
||||||
|
// Remove any password-related keys; we never query by them
|
||||||
|
$credentials = array_filter(
|
||||||
|
$credentials,
|
||||||
|
fn ($key) => ! str_contains($key, 'password'),
|
||||||
|
ARRAY_FILTER_USE_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle the common Fortify username alias `login` (email or username)
|
||||||
|
if (array_key_exists('login', $credentials)) {
|
||||||
|
$login = $credentials['login'];
|
||||||
|
unset($credentials['login']); // prevent accidental `where login = ?`
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are no identifier credentials and no `login`, fall back to the
|
||||||
|
// currently authenticated user (used by Fortify confirm-password flow).
|
||||||
|
if ((empty($credentials)) && (!isset($login) || $login === null || $login === '')) {
|
||||||
|
$currentId = Auth::id();
|
||||||
|
if ($currentId) {
|
||||||
|
return $this->retrieveById($currentId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = $this->newModelQuery();
|
||||||
|
|
||||||
|
// If a generic `login` was provided, match against blind indexes for email/username
|
||||||
|
if (isset($login) && is_string($login) && $login !== '') {
|
||||||
|
if (strlen($login) === 64 && ctype_xdigit($login)) {
|
||||||
|
// Already a sha256 hash
|
||||||
|
$query->where(function ($q) use ($login) {
|
||||||
|
$q->where('email_index', $login)
|
||||||
|
->orWhere('username_index', $login);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$hash = hash('sha256', $login);
|
||||||
|
$query->where(function ($q) use ($hash) {
|
||||||
|
$q->where('email_index', $hash)
|
||||||
|
->orWhere('username_index', $hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply any remaining credential filters safely
|
||||||
|
foreach ($credentials as $key => $value) {
|
||||||
|
// Skip empty scalars to avoid `WHERE <field> IS NULL` and unknown columns
|
||||||
|
if ($value === null || (is_string($value) && $value === '')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($value) || $value instanceof Arrayable) {
|
||||||
|
// Only use whereIn for known lookup keys
|
||||||
|
if (in_array($key, ['id', 'email', 'username', 'email_index', 'username_index'], true)) {
|
||||||
|
$query->whereIn($key, $value);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($key === 'email') {
|
||||||
|
// Accept either plaintext email (hashed) or a precomputed hash
|
||||||
|
if (strlen($value) === 64 && ctype_xdigit($value)) {
|
||||||
|
$query->where('email_index', $value);
|
||||||
|
} else {
|
||||||
|
$query->where('email_index', hash('sha256', $value));
|
||||||
|
}
|
||||||
|
} elseif ($key === 'username') {
|
||||||
|
// Use blind index for username lookup
|
||||||
|
$query->where('username_index', hash('sha256', $value));
|
||||||
|
} elseif (in_array($key, ['id', 'email_index', 'username_index'], true)) {
|
||||||
|
// Allow direct lookups on safe columns only
|
||||||
|
$query->where($key, $value);
|
||||||
|
} else {
|
||||||
|
// Ignore unknown/non-whitelisted keys to avoid querying non-existent columns
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Casts/EncryptedDecimal.php
Normal file
75
app/Casts/EncryptedDecimal.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Casts;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted decimal stored as ciphertext (string) with fixed scale.
|
||||||
|
* Works with string or numeric inputs. Always returns string with fixed scale.
|
||||||
|
*/
|
||||||
|
class EncryptedDecimal implements CastsAttributes
|
||||||
|
{
|
||||||
|
private int $scale;
|
||||||
|
|
||||||
|
public function __construct(int $scale = 4)
|
||||||
|
{
|
||||||
|
if ($scale < 0 || $scale > 18) {
|
||||||
|
throw new InvalidArgumentException('Invalid scale for EncryptedDecimal.');
|
||||||
|
}
|
||||||
|
$this->scale = $scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get($model, string $key, $value, array $attributes)
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
// Treat null as zero but do not mutate DB implicitly
|
||||||
|
return number_format(0, $this->scale, '.', '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$plain = Crypt::decryptString((string) $value);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// If value is not decryptable (legacy/plain), try to normalize as plain string
|
||||||
|
$plain = (string) $value;
|
||||||
|
}
|
||||||
|
return $this->normalize($plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function set($model, string $key, $value, array $attributes)
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return [$key => null];
|
||||||
|
}
|
||||||
|
$normalized = $this->normalize($value);
|
||||||
|
return [$key => Crypt::encryptString($normalized)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize($value): string
|
||||||
|
{
|
||||||
|
// Accept numeric, string with comma/dot; normalize to string with fixed scale
|
||||||
|
$s = is_string($value) ? trim($value) : (string) $value;
|
||||||
|
$s = str_replace([',', ' '], ['', ''], $s);
|
||||||
|
if (!preg_match('/^-?\d*(?:\.\d+)?$/', $s)) {
|
||||||
|
throw new InvalidArgumentException('Invalid decimal value.');
|
||||||
|
}
|
||||||
|
if ($s === '' || $s === '-') {
|
||||||
|
$s = '0';
|
||||||
|
}
|
||||||
|
// Use integer math on scaled value to avoid float precision
|
||||||
|
$scale = $this->scale;
|
||||||
|
// Split integer and fractional part
|
||||||
|
$neg = str_starts_with($s, '-') ? '-' : '';
|
||||||
|
if ($neg) $s = substr($s, 1);
|
||||||
|
[$int, $frac] = array_pad(explode('.', $s, 2), 2, '');
|
||||||
|
$frac = substr($frac . str_repeat('0', $scale), 0, $scale);
|
||||||
|
$intPart = ltrim($int === '' ? '0' : $int, '0');
|
||||||
|
if ($intPart === '') { $intPart = '0'; }
|
||||||
|
$out = ($neg ? '-' : '') . $intPart;
|
||||||
|
if ($scale > 0) {
|
||||||
|
$out .= '.' . $frac;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Casts/SafeEncryptedString.php
Normal file
88
app/Casts/SafeEncryptedString.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Casts;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tolerant encrypt/decrypt cast for string columns where the database
|
||||||
|
* may contain a mix of plaintext and Laravel-encrypted payloads.
|
||||||
|
*
|
||||||
|
* - On get: tries to decrypt; if it fails, returns the original value.
|
||||||
|
* - On set: encrypts plaintext; if already encrypted, stores as-is.
|
||||||
|
*/
|
||||||
|
class SafeEncryptedString implements CastsAttributes
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the attribute from the underlying model values.
|
||||||
|
*
|
||||||
|
* @param mixed $model
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string,mixed> $attributes
|
||||||
|
*/
|
||||||
|
public function get($model, string $key, $value, array $attributes)
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($value);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Not an encrypted payload (or bad key) — return as-is.
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare the given value for storage.
|
||||||
|
*
|
||||||
|
* @param mixed $model
|
||||||
|
* @param string $key
|
||||||
|
* @param mixed $value
|
||||||
|
* @param array<string,mixed> $attributes
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function set($model, string $key, $value, array $attributes)
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stringValue = (string) $value;
|
||||||
|
|
||||||
|
// Ziel: Immer Klartext in der DB speichern.
|
||||||
|
// Wenn ein verschlüsselter Laravel-Payload übergeben wurde, entschlüsseln und im Klartext ablegen.
|
||||||
|
if (self::looksEncrypted($stringValue)) {
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($stringValue);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Falls Entschlüsselung (noch) nicht möglich: als Fallback unverändert ablegen,
|
||||||
|
// aber bevorzugt sollte der Aufrufer bereits Klartext liefern.
|
||||||
|
return $stringValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bereits Klartext → so speichern
|
||||||
|
return $stringValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function looksEncrypted(string $value): bool
|
||||||
|
{
|
||||||
|
// Laravel's Crypt::encryptString returns base64-encoded JSON string
|
||||||
|
// with keys like iv/value/mac and sometimes tag.
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$json = json_decode($decoded, true);
|
||||||
|
if (!is_array($json)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$hasCoreKeys = isset($json['iv'], $json['value'], $json['mac']);
|
||||||
|
// 'tag' may or may not exist depending on cipher/version
|
||||||
|
return $hasCoreKeys;
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Concerns/PasswordValidationRules.php
Normal file
28
app/Concerns/PasswordValidationRules.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate the current password.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function currentPasswordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', 'current_password'];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Concerns/ProfileValidationRules.php
Normal file
62
app/Concerns/ProfileValidationRules.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Concerns;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
trait ProfileValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user profiles.
|
||||||
|
*
|
||||||
|
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
|
||||||
|
*/
|
||||||
|
protected function profileRules(?int $userId = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->nameRules(),
|
||||||
|
'email' => $this->emailRules($userId),
|
||||||
|
// Optional extended profile fields
|
||||||
|
'first_name' => ['nullable','string','max:60'],
|
||||||
|
'last_name' => ['nullable','string','max:60'],
|
||||||
|
'gender' => ['nullable','string','max:24'],
|
||||||
|
'birthdate' => ['nullable','date','before:today'],
|
||||||
|
'country' => ['nullable','string','size:2'],
|
||||||
|
'address_line1' => ['nullable','string','max:120'],
|
||||||
|
'address_line2' => ['nullable','string','max:120'],
|
||||||
|
'city' => ['nullable','string','max:80'],
|
||||||
|
'state' => ['nullable','string','max:80'],
|
||||||
|
'postal_code' => ['nullable','string','max:32'],
|
||||||
|
'phone' => ['nullable','string','max:32'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user names.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function nameRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', 'max:255'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate user emails.
|
||||||
|
*
|
||||||
|
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function emailRules(?int $userId = null): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
$userId === null
|
||||||
|
? Rule::unique(User::class)
|
||||||
|
: Rule::unique(User::class)->ignore($userId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Console/Commands/OperatorCreateCasino.php
Normal file
55
app/Console/Commands/OperatorCreateCasino.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\OperatorCasino;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class OperatorCreateCasino extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'operator:create-casino
|
||||||
|
{name : Display name of the casino / operator}
|
||||||
|
{--ip=* : Allowed IP addresses (repeat for multiple)}
|
||||||
|
{--domain=* : Allowed domains (repeat for multiple)}';
|
||||||
|
|
||||||
|
protected $description = 'Create a new B2B operator casino account and generate a betix_... license key';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$name = $this->argument('name');
|
||||||
|
|
||||||
|
// Generate key in format betix_{64 hex chars}
|
||||||
|
$licenseKey = 'betix_' . bin2hex(random_bytes(32));
|
||||||
|
|
||||||
|
$ipWhitelist = array_filter($this->option('ip'));
|
||||||
|
$domainWhitelist = array_filter($this->option('domain'));
|
||||||
|
|
||||||
|
$casino = OperatorCasino::create([
|
||||||
|
'name' => $name,
|
||||||
|
'license_key_hash' => hash('sha256', $licenseKey),
|
||||||
|
'status' => 'active',
|
||||||
|
'ip_whitelist' => !empty($ipWhitelist) ? array_values($ipWhitelist) : null,
|
||||||
|
'domain_whitelist' => !empty($domainWhitelist) ? array_values($domainWhitelist) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->components->success("Operator casino created: [{$casino->id}] {$casino->name}");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->components->warn('⚠ Save this license key — it will NOT be shown again:');
|
||||||
|
$this->line('');
|
||||||
|
$this->line(" <fg=green;options=bold>{$licenseKey}</>");
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (!empty($casino->ip_whitelist)) {
|
||||||
|
$this->line('IP whitelist: ' . implode(', ', $casino->ip_whitelist));
|
||||||
|
}
|
||||||
|
if (!empty($casino->domain_whitelist)) {
|
||||||
|
$this->line('Domain whitelist: ' . implode(', ', $casino->domain_whitelist));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Console/Commands/ReencryptUserData.php
Normal file
44
app/Console/Commands/ReencryptUserData.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ReencryptUserData extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'data:reencrypt-users {--chunk=500}';
|
||||||
|
protected $description = 'Re-read and re-save user fields to normalize encryption and blind indices';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$chunk = (int) $this->option('chunk');
|
||||||
|
$updated = 0;
|
||||||
|
|
||||||
|
// We want small transactions per chunk to avoid long locks
|
||||||
|
User::query()->orderBy('id')
|
||||||
|
->chunkById($chunk, function ($users) use (&$updated) {
|
||||||
|
DB::transaction(function () use ($users, &$updated) {
|
||||||
|
foreach ($users as $u) {
|
||||||
|
// Read via casts (plaintext) and assign back to trigger normalization
|
||||||
|
$email = $u->email;
|
||||||
|
$username = $u->username;
|
||||||
|
|
||||||
|
$dirty = false;
|
||||||
|
if ($email !== null) { $u->email = $email; $dirty = true; }
|
||||||
|
if ($username !== null) { $u->username = $username; $dirty = true; }
|
||||||
|
|
||||||
|
if ($dirty) {
|
||||||
|
$u->saveQuietly();
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Users normalized: {$updated}");
|
||||||
|
$this->info('Tip: Remove APP_PREVIOUS_KEYS after normalization if keys are unified.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/Admin/GeoBlockController.php
Normal file
56
app/Http/Controllers/Admin/GeoBlockController.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class GeoBlockController extends Controller
|
||||||
|
{
|
||||||
|
private const KEY = 'geo.settings';
|
||||||
|
|
||||||
|
private array $defaults = [
|
||||||
|
'enabled' => false,
|
||||||
|
'blocked_countries' => [],
|
||||||
|
'allowed_countries' => [],
|
||||||
|
'mode' => 'blacklist', // 'blacklist' or 'whitelist'
|
||||||
|
'vpn_block' => false,
|
||||||
|
'vpn_provider' => 'none', // 'none', 'ipqualityscore', 'proxycheck'
|
||||||
|
'vpn_api_key' => '',
|
||||||
|
'block_message' => 'This service is not available in your region.',
|
||||||
|
'redirect_url' => '',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$saved = AppSetting::get(self::KEY, []);
|
||||||
|
$settings = array_merge($this->defaults, is_array($saved) ? $saved : []);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/GeoBlock', [
|
||||||
|
'settings' => $settings,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'mode' => 'required|in:blacklist,whitelist',
|
||||||
|
'blocked_countries' => 'array',
|
||||||
|
'blocked_countries.*' => 'string|size:2',
|
||||||
|
'allowed_countries' => 'array',
|
||||||
|
'allowed_countries.*' => 'string|size:2',
|
||||||
|
'vpn_block' => 'boolean',
|
||||||
|
'vpn_provider' => 'required|in:none,ipqualityscore,proxycheck',
|
||||||
|
'vpn_api_key' => 'nullable|string|max:200',
|
||||||
|
'block_message' => 'required|string|max:500',
|
||||||
|
'redirect_url' => 'nullable|url|max:500',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AppSetting::put(self::KEY, $data);
|
||||||
|
|
||||||
|
return back()->with('success', 'GeoBlock settings saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Http/Controllers/Admin/PaymentsSettingsController.php
Normal file
130
app/Http/Controllers/Admin/PaymentsSettingsController.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use App\Services\DepositService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class PaymentsSettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DepositService $deposits)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/payments/settings
|
||||||
|
*/
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||||
|
|
||||||
|
$settings = $this->deposits->getSettings();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/PaymentsSettings', [
|
||||||
|
'settings' => $settings,
|
||||||
|
'defaults' => [
|
||||||
|
'commonCurrencies' => ['BTC','ETH','LTC','SOL','USDT_ERC20','USDT_TRC20','BCH','DOGE'],
|
||||||
|
'modes' => ['live','sandbox'],
|
||||||
|
'addressModes' => ['per_payment','per_user'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/payments/settings
|
||||||
|
*/
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'mode' => ['required','in:live,sandbox'],
|
||||||
|
'api_key' => ['nullable','string','max:200'],
|
||||||
|
'ipn_secret' => ['nullable','string','max:200'],
|
||||||
|
'enabled_currencies' => ['required','array','min:1'],
|
||||||
|
'enabled_currencies.*' => ['string','max:32'],
|
||||||
|
'global_min_usd' => ['required','numeric','min:0'],
|
||||||
|
'global_max_usd' => ['required','numeric','gt:global_min_usd'],
|
||||||
|
'btx_per_usd' => ['required','numeric','min:0.00000001'],
|
||||||
|
'per_currency_overrides' => ['sometimes','array'],
|
||||||
|
'per_currency_overrides.*.min_usd' => ['nullable','numeric','min:0'],
|
||||||
|
'per_currency_overrides.*.max_usd' => ['nullable','numeric'],
|
||||||
|
'per_currency_overrides.*.btx_per_usd' => ['nullable','numeric','min:0.00000001'],
|
||||||
|
'success_url' => ['required','string','max:255'],
|
||||||
|
'cancel_url' => ['required','string','max:255'],
|
||||||
|
'address_mode' => ['required','in:per_payment,per_user'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Normalize overrides structure as map keyed by currency
|
||||||
|
$overrides = [];
|
||||||
|
if (!empty($data['per_currency_overrides']) && is_array($data['per_currency_overrides'])) {
|
||||||
|
foreach ($data['per_currency_overrides'] as $cur => $vals) {
|
||||||
|
if (is_array($vals)) {
|
||||||
|
$entry = [];
|
||||||
|
if (array_key_exists('min_usd', $vals) && $vals['min_usd'] !== null) $entry['min_usd'] = (float) $vals['min_usd'];
|
||||||
|
if (array_key_exists('max_usd', $vals) && $vals['max_usd'] !== null) $entry['max_usd'] = (float) $vals['max_usd'];
|
||||||
|
if (array_key_exists('btx_per_usd', $vals) && $vals['btx_per_usd'] !== null) $entry['btx_per_usd'] = (float) $vals['btx_per_usd'];
|
||||||
|
if (!empty($entry)) {
|
||||||
|
$overrides[strtoupper($cur)] = $entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve existing api_key/ipn_secret if not re-submitted (masked fields)
|
||||||
|
$existing = AppSetting::get('payments.nowpayments', []);
|
||||||
|
$apiKey = $data['api_key'] ?? null;
|
||||||
|
$ipnSecret = $data['ipn_secret'] ?? null;
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'mode' => $data['mode'],
|
||||||
|
'api_key' => $apiKey ?: ($existing['api_key'] ?? ''),
|
||||||
|
'ipn_secret' => $ipnSecret ?: ($existing['ipn_secret'] ?? ''),
|
||||||
|
'enabled_currencies' => array_values(array_map('strtoupper', $data['enabled_currencies'])),
|
||||||
|
'global_min_usd' => (float) $data['global_min_usd'],
|
||||||
|
'global_max_usd' => (float) $data['global_max_usd'],
|
||||||
|
'btx_per_usd' => (float) $data['btx_per_usd'],
|
||||||
|
'per_currency_overrides' => $overrides,
|
||||||
|
'success_url' => (string) $data['success_url'],
|
||||||
|
'cancel_url' => (string) $data['cancel_url'],
|
||||||
|
'address_mode' => (string) $data['address_mode'],
|
||||||
|
];
|
||||||
|
|
||||||
|
AppSetting::put('payments.nowpayments', $payload);
|
||||||
|
|
||||||
|
return back()->with('success', 'Payment settings saved.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/payments/test
|
||||||
|
*/
|
||||||
|
public function test(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user && in_array(strtolower((string) $user->role), ['admin', 'owner']), 403);
|
||||||
|
|
||||||
|
$data = $request->validate(['api_key' => 'required|string|max:200']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(8)->withHeaders([
|
||||||
|
'x-api-key' => $data['api_key'],
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
])->get('https://api.nowpayments.io/v1/status');
|
||||||
|
|
||||||
|
if ($res->ok()) {
|
||||||
|
return response()->json(['ok' => true, 'message' => 'Verbindung erfolgreich! NOWPayments API erreichbar.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => false, 'message' => 'API antwortet mit Status ' . $res->status() . '. API Key prüfen.'], 422);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['ok' => false, 'message' => 'Verbindung fehlgeschlagen: ' . $e->getMessage()], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Http/Controllers/Admin/PromoAdminController.php
Normal file
130
app/Http/Controllers/Admin/PromoAdminController.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PromoAdminController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertAdmin(): void
|
||||||
|
{
|
||||||
|
if (!Auth::check() || strtolower((string) Auth::user()->role) !== 'admin') {
|
||||||
|
abort(403, 'Nur für Admins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show simple admin page to manage promos (data from upstream)
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
|
||||||
|
$promos = [];
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/admin/promos', ['per_page' => 20], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$j = $res->json() ?: [];
|
||||||
|
$promos = $j['data'] ?? $j['promos'] ?? $j;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// show empty list with error message on page via flash if desired
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Promos', [
|
||||||
|
'promos' => $promos,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new promo via upstream
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
|
||||||
|
$data = $this->validateData($request);
|
||||||
|
$data['code'] = strtoupper(trim($data['code']));
|
||||||
|
$data['is_active'] = $data['is_active'] ?? true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, '/admin/promos', $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return back()->with('success', 'Promo erstellt.');
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Ungültige Eingabe');
|
||||||
|
return back()->withErrors(['promo' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['promo' => 'Service temporär nicht verfügbar']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing promo via upstream
|
||||||
|
*/
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
|
||||||
|
$data = $this->validateData($request, $id);
|
||||||
|
if (isset($data['code'])) {
|
||||||
|
$data['code'] = strtoupper(trim($data['code']));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->patch($request, "/admin/promos/{$id}", $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return back()->with('success', 'Promo aktualisiert.');
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Ungültige Eingabe');
|
||||||
|
return back()->withErrors(['promo' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['promo' => 'Service temporär nicht verfügbar']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['promo' => 'API Server nicht erreichbar']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateData(Request $request, ?int $ignoreId = null): array
|
||||||
|
{
|
||||||
|
$isUpdate = $request->isMethod('patch') || $request->isMethod('put');
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'code' => [($isUpdate ? 'sometimes' : 'required'), 'string', 'max:64'],
|
||||||
|
'description' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'string', 'max:255'],
|
||||||
|
'bonus_amount' => [($isUpdate ? 'sometimes' : 'required'), 'numeric', 'min:0'],
|
||||||
|
'wager_multiplier' => [($isUpdate ? 'sometimes' : 'required'), 'integer', 'min:0', 'max:1000'],
|
||||||
|
'per_user_limit' => [($isUpdate ? 'sometimes' : 'required'), 'integer', 'min:1', 'max:1000'],
|
||||||
|
'global_limit' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'integer', 'min:1', 'max:1000000'],
|
||||||
|
'starts_at' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'date'],
|
||||||
|
'ends_at' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||||
|
'min_deposit' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'numeric', 'min:0'],
|
||||||
|
'bonus_expires_days' => [($isUpdate ? 'sometimes' : 'nullable'), 'nullable', 'integer', 'min:1', 'max:365'],
|
||||||
|
'is_active' => [($isUpdate ? 'sometimes' : 'nullable'), 'boolean'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $request->validate($rules);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
app/Http/Controllers/Admin/SiteSettingsController.php
Normal file
79
app/Http/Controllers/Admin/SiteSettingsController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SiteSettingsController extends Controller
|
||||||
|
{
|
||||||
|
private const KEY = 'site.settings';
|
||||||
|
|
||||||
|
private array $defaults = [
|
||||||
|
'site_name' => 'BetiX Casino',
|
||||||
|
'site_tagline' => 'Play. Win. Repeat.',
|
||||||
|
'primary_color' => '#df006a',
|
||||||
|
'logo_url' => '',
|
||||||
|
'favicon_url' => '',
|
||||||
|
'maintenance_mode' => false,
|
||||||
|
'registration_open' => true,
|
||||||
|
'min_deposit_usd' => 10,
|
||||||
|
'max_deposit_usd' => 50000,
|
||||||
|
'min_withdrawal_usd' => 20,
|
||||||
|
'max_withdrawal_usd' => 100000,
|
||||||
|
'max_bet_usd' => 5000,
|
||||||
|
'house_edge_percent' => 1.0,
|
||||||
|
'footer_text' => '',
|
||||||
|
'support_email' => '',
|
||||||
|
'terms_url' => '/terms',
|
||||||
|
'privacy_url' => '/privacy',
|
||||||
|
'currency_symbol' => 'BTX',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$saved = AppSetting::get(self::KEY, []);
|
||||||
|
$settings = array_merge($this->defaults, is_array($saved) ? $saved : []);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/SiteSettings', [
|
||||||
|
'settings' => $settings,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
// Normalize empty strings to null so URL/email validation doesn't fail on blank fields
|
||||||
|
foreach (['logo_url', 'favicon_url', 'terms_url', 'privacy_url', 'support_email', 'site_tagline', 'footer_text'] as $field) {
|
||||||
|
if ($request->input($field) === '') {
|
||||||
|
$request->merge([$field => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'site_name' => 'required|string|max:100',
|
||||||
|
'site_tagline' => 'nullable|string|max:200',
|
||||||
|
'primary_color' => 'required|regex:/^#[0-9a-fA-F]{6}$/',
|
||||||
|
'logo_url' => 'nullable|url|max:500',
|
||||||
|
'favicon_url' => 'nullable|url|max:500',
|
||||||
|
'maintenance_mode' => 'boolean',
|
||||||
|
'registration_open' => 'boolean',
|
||||||
|
'min_deposit_usd' => 'required|numeric|min:0',
|
||||||
|
'max_deposit_usd' => 'required|numeric|min:0',
|
||||||
|
'min_withdrawal_usd' => 'required|numeric|min:0',
|
||||||
|
'max_withdrawal_usd' => 'required|numeric|min:0',
|
||||||
|
'max_bet_usd' => 'required|numeric|min:0',
|
||||||
|
'house_edge_percent' => 'required|numeric|min:0|max:100',
|
||||||
|
'footer_text' => 'nullable|string|max:1000',
|
||||||
|
'support_email' => 'nullable|email|max:200',
|
||||||
|
'terms_url' => 'nullable|string|max:500',
|
||||||
|
'privacy_url' => 'nullable|string|max:500',
|
||||||
|
'currency_symbol' => 'required|string|max:10',
|
||||||
|
]);
|
||||||
|
|
||||||
|
AppSetting::put(self::KEY, $data);
|
||||||
|
|
||||||
|
return back()->with('success', 'Site settings saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Http/Controllers/Admin/SupportAdminController.php
Normal file
127
app/Http/Controllers/Admin/SupportAdminController.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class SupportAdminController extends Controller
|
||||||
|
{
|
||||||
|
private function assertAdmin(): void
|
||||||
|
{
|
||||||
|
if (!Auth::check() || strtolower((string) Auth::user()->role) !== 'admin') {
|
||||||
|
abort(403, 'Nur für Admins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ollamaStatus(): array
|
||||||
|
{
|
||||||
|
$host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/');
|
||||||
|
$model = env('OLLAMA_MODEL', 'llama3');
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(2)->get($host . '/api/tags');
|
||||||
|
return ['healthy' => $res->ok(), 'host' => $host, 'model' => $model, 'error' => $res->ok() ? null : 'Keine Verbindung'];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['healthy' => false, 'host' => $host, 'model' => $model, 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getThreads(): array
|
||||||
|
{
|
||||||
|
$index = cache()->get('support_threads_index', []);
|
||||||
|
$threads = [];
|
||||||
|
foreach (array_reverse($index) as $row) {
|
||||||
|
$full = cache()->get('support_threads:' . $row['id']);
|
||||||
|
if (is_array($full)) {
|
||||||
|
$threads[] = $full;
|
||||||
|
} else {
|
||||||
|
$threads[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $threads;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
|
||||||
|
$enabled = (bool) (cache()->get('support_chat_enabled') ?? config('app.support_chat_enabled', true));
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Support', [
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'threads' => $this->getThreads(),
|
||||||
|
'ollama' => $this->ollamaStatus(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function settings(Request $request)
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
$data = $request->validate(['enabled' => 'required|boolean']);
|
||||||
|
cache()->put('support_chat_enabled', (bool) $data['enabled'], now()->addYear());
|
||||||
|
return back()->with('success', 'Support-Chat Einstellungen gespeichert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reply(Request $request, string $thread)
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
$data = $request->validate(['text' => 'required|string|min:1|max:1000']);
|
||||||
|
|
||||||
|
$record = cache()->get('support_threads:' . $thread);
|
||||||
|
if (!is_array($record)) {
|
||||||
|
return back()->withErrors(['text' => 'Thread nicht gefunden.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record['messages'][] = [
|
||||||
|
'id' => Str::uuid()->toString(),
|
||||||
|
'sender' => 'agent',
|
||||||
|
'body' => $data['text'],
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
$record['status'] = 'agent';
|
||||||
|
$record['updated_at'] = now()->toIso8601String();
|
||||||
|
|
||||||
|
cache()->put('support_threads:' . $thread, $record, now()->addDay());
|
||||||
|
|
||||||
|
// Update index entry
|
||||||
|
$index = cache()->get('support_threads_index', []);
|
||||||
|
foreach ($index as &$row) {
|
||||||
|
if (($row['id'] ?? null) === $thread) {
|
||||||
|
$row['status'] = 'agent';
|
||||||
|
$row['updated_at'] = $record['updated_at'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->put('support_threads_index', $index, now()->addDay());
|
||||||
|
|
||||||
|
return back()->with('success', 'Nachricht gesendet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(Request $request, string $thread)
|
||||||
|
{
|
||||||
|
$this->assertAdmin();
|
||||||
|
|
||||||
|
$record = cache()->get('support_threads:' . $thread);
|
||||||
|
if (is_array($record)) {
|
||||||
|
$record['status'] = 'closed';
|
||||||
|
$record['updated_at'] = now()->toIso8601String();
|
||||||
|
cache()->put('support_threads:' . $thread, $record, now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = cache()->get('support_threads_index', []);
|
||||||
|
foreach ($index as &$row) {
|
||||||
|
if (($row['id'] ?? null) === $thread) {
|
||||||
|
$row['status'] = 'closed';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache()->put('support_threads_index', $index, now()->addDay());
|
||||||
|
|
||||||
|
return back()->with('success', 'Chat geschlossen.');
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Http/Controllers/Admin/WalletsAdminController.php
Normal file
73
app/Http/Controllers/Admin/WalletsAdminController.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class WalletsAdminController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /admin/wallets/settings — Wallet/Vault policies and limits.
|
||||||
|
*/
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user && ($user->role === 'Admin' || $user->role === 'Owner'), 403);
|
||||||
|
|
||||||
|
$defaults = [
|
||||||
|
'pin_max_attempts' => 5,
|
||||||
|
'pin_lock_minutes' => 15,
|
||||||
|
'min_tx_btx' => 0.0001,
|
||||||
|
'max_tx_btx' => 100000,
|
||||||
|
'daily_max_btx' => 100000,
|
||||||
|
'actions_per_minute' => 20,
|
||||||
|
'reason_required' => true,
|
||||||
|
];
|
||||||
|
$settings = AppSetting::get('wallet.settings', $defaults) ?: $defaults;
|
||||||
|
// Ensure defaults filled
|
||||||
|
$settings = array_replace($defaults, is_array($settings) ? $settings : []);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/WalletsSettings', [
|
||||||
|
'settings' => $settings,
|
||||||
|
'defaults' => $defaults,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/wallets/settings — Save policies and limits.
|
||||||
|
*/
|
||||||
|
public function save(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user && ($user->role === 'Admin' || $user->role === 'Owner'), 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pin_max_attempts' => ['required','integer','min:1','max:20'],
|
||||||
|
'pin_lock_minutes' => ['required','integer','min:1','max:1440'],
|
||||||
|
'min_tx_btx' => ['required','numeric','min:0'],
|
||||||
|
'max_tx_btx' => ['required','numeric','gt:min_tx_btx'],
|
||||||
|
'daily_max_btx' => ['required','numeric','gte:max_tx_btx'],
|
||||||
|
'actions_per_minute' => ['required','integer','min:1','max:600'],
|
||||||
|
'reason_required' => ['required','boolean'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Normalize numeric precision (BTX uses 4 decimals commonly)
|
||||||
|
$payload = [
|
||||||
|
'pin_max_attempts' => (int) $data['pin_max_attempts'],
|
||||||
|
'pin_lock_minutes' => (int) $data['pin_lock_minutes'],
|
||||||
|
'min_tx_btx' => round((float) $data['min_tx_btx'], 4),
|
||||||
|
'max_tx_btx' => round((float) $data['max_tx_btx'], 4),
|
||||||
|
'daily_max_btx' => round((float) $data['daily_max_btx'], 4),
|
||||||
|
'actions_per_minute' => (int) $data['actions_per_minute'],
|
||||||
|
'reason_required' => (bool) $data['reason_required'],
|
||||||
|
];
|
||||||
|
|
||||||
|
AppSetting::put('wallet.settings', $payload);
|
||||||
|
|
||||||
|
return back()->with('success', 'Wallet settings saved.');
|
||||||
|
}
|
||||||
|
}
|
||||||
604
app/Http/Controllers/AdminController.php
Normal file
604
app/Http/Controllers/AdminController.php
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ChatMessageReport;
|
||||||
|
use App\Models\ProfileComment;
|
||||||
|
use App\Models\ProfileLike;
|
||||||
|
use App\Models\ProfileReport;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\WalletTransfer;
|
||||||
|
use App\Models\GameBet;
|
||||||
|
use App\Models\UserRestriction;
|
||||||
|
use App\Models\KycDocument;
|
||||||
|
use App\Models\CryptoPayment;
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
class AdminController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureAdmin()
|
||||||
|
{
|
||||||
|
if (strtolower((string) Auth::user()->role) !== 'admin') {
|
||||||
|
abort(403, 'Nur für Admins');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect old /admin to the new dashboard
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
return redirect()->route('admin.casino');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function casinoDashboard(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total_users' => User::count(),
|
||||||
|
'total_wagered' => GameBet::sum('wager_amount'),
|
||||||
|
'total_payout' => GameBet::sum('payout_amount'),
|
||||||
|
'active_bans' => User::where('is_banned', true)->count(),
|
||||||
|
'new_users_24h' => User::where('created_at', '>=', now()->subDay())->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$stats['house_edge'] = $stats['total_wagered'] - $stats['total_payout'];
|
||||||
|
|
||||||
|
// Get chart data for the last 7 days
|
||||||
|
$chartData = [];
|
||||||
|
for ($i = 6; $i >= 0; $i--) {
|
||||||
|
$date = Carbon::today()->subDays($i);
|
||||||
|
$nextDate = Carbon::today()->subDays($i - 1);
|
||||||
|
|
||||||
|
$wagered = GameBet::whereBetween('created_at', [$date, $nextDate])->sum('wager_amount');
|
||||||
|
$payout = GameBet::whereBetween('created_at', [$date, $nextDate])->sum('payout_amount');
|
||||||
|
$newUsers = User::whereBetween('created_at', [$date, $nextDate])->count();
|
||||||
|
|
||||||
|
$chartData[] = [
|
||||||
|
'date' => $date->format('Y-m-d'),
|
||||||
|
'label' => $date->format('D, d M'),
|
||||||
|
'wagered' => (float)$wagered,
|
||||||
|
'payout' => (float)$payout,
|
||||||
|
'profit' => (float)($wagered - $payout),
|
||||||
|
'new_users' => $newUsers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$recent_bets = GameBet::with('user:id,username')
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$recent_users = User::orderByDesc('id')
|
||||||
|
->limit(10)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/CasinoDashboard', [
|
||||||
|
'stats' => $stats,
|
||||||
|
'chartData' => $chartData,
|
||||||
|
'recentBets' => $recent_bets,
|
||||||
|
'recentUsers' => $recent_users,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usersIndex(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$query = User::orderByDesc('id');
|
||||||
|
|
||||||
|
if ($request->has('search')) {
|
||||||
|
$search = $request->input('search');
|
||||||
|
$query->where(function($q) use ($search) {
|
||||||
|
$q->where('id', 'like', "%$search%")
|
||||||
|
->orWhere('username', 'like', "%$search%")
|
||||||
|
->orWhere('email', 'like', "%$search%")
|
||||||
|
->orWhere('first_name', 'like', "%$search%")
|
||||||
|
->orWhere('last_name', 'like', "%$search%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has('role') && $request->input('role') !== '') {
|
||||||
|
$query->where('role', $request->input('role'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$users = $query->paginate(20)->withQueryString();
|
||||||
|
$roles = ['Admin', 'Moderator', 'User'];
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Users', [
|
||||||
|
'users' => $users,
|
||||||
|
'roles' => $roles,
|
||||||
|
'filters' => $request->only(['search', 'role'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userShow(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
$user = User::with('wallets')->findOrFail($id);
|
||||||
|
|
||||||
|
$restrictions = UserRestriction::where('user_id', $user->id)->orderByDesc('id')->get();
|
||||||
|
|
||||||
|
$vaultTransfers = WalletTransfer::where('user_id', $user->id)
|
||||||
|
->whereIn('type', ['vault_deposit', 'vault_withdraw'])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$deposits = CryptoPayment::where('user_id', $user->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$kycDocuments = class_exists(KycDocument::class)
|
||||||
|
? KycDocument::where('user_id', $user->id)->get()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Inertia::render('Admin/UserShow', [
|
||||||
|
'user' => $user,
|
||||||
|
'restrictions' => $restrictions,
|
||||||
|
'wallets' => $user->wallets,
|
||||||
|
'vaultTransfers' => $vaultTransfers,
|
||||||
|
'deposits' => $deposits,
|
||||||
|
'kycDocuments' => $kycDocuments,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateUser(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'username' => 'required|string|max:255',
|
||||||
|
'email' => 'required|email|max:255',
|
||||||
|
'first_name' => 'nullable|string|max:255',
|
||||||
|
'last_name' => 'nullable|string|max:255',
|
||||||
|
'birthdate' => 'nullable|date',
|
||||||
|
'gender' => 'nullable|string|max:50',
|
||||||
|
'phone' => 'nullable|string|max:50',
|
||||||
|
'country' => 'nullable|string|max:10',
|
||||||
|
'address_line1' => 'nullable|string|max:255',
|
||||||
|
'address_line2' => 'nullable|string|max:255',
|
||||||
|
'city' => 'nullable|string|max:255',
|
||||||
|
'postal_code' => 'nullable|string|max:50',
|
||||||
|
'currency' => 'nullable|string|max:10',
|
||||||
|
'vip_level' => 'nullable|integer|min:0|max:100',
|
||||||
|
'balance' => 'nullable|numeric',
|
||||||
|
'vault_balance' => 'nullable|numeric',
|
||||||
|
'is_banned' => 'boolean',
|
||||||
|
'is_chat_banned' => 'boolean',
|
||||||
|
'ban_reason' => 'nullable|string|max:255',
|
||||||
|
'role' => 'nullable|string|max:50'
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::transaction(function() use ($id, $validated, $request) {
|
||||||
|
$user = User::where('id', $id)->lockForUpdate()->firstOrFail();
|
||||||
|
|
||||||
|
$user->fill($validated);
|
||||||
|
|
||||||
|
// Update restrictions for ban
|
||||||
|
if ($request->has('is_banned')) {
|
||||||
|
$user->is_banned = $request->input('is_banned');
|
||||||
|
if ($user->is_banned) {
|
||||||
|
UserRestriction::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'type' => 'account_ban'],
|
||||||
|
[
|
||||||
|
'active' => true,
|
||||||
|
'reason' => $request->input('ban_reason'),
|
||||||
|
'expires_at' => $request->input('ban_ends_at')
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
UserRestriction::where('user_id', $user->id)->where('type', 'account_ban')->update(['active' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update restrictions for chat ban (via UserRestriction only, no column on users table)
|
||||||
|
if ($request->has('is_chat_banned')) {
|
||||||
|
if ($request->boolean('is_chat_banned')) {
|
||||||
|
UserRestriction::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'type' => 'chat_ban'],
|
||||||
|
[
|
||||||
|
'active' => true,
|
||||||
|
'reason' => 'Admin intervention',
|
||||||
|
'ends_at' => $request->input('chat_ban_ends_at'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
UserRestriction::where('user_id', $user->id)->where('type', 'chat_ban')->update(['active' => false]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->save();
|
||||||
|
});
|
||||||
|
|
||||||
|
return back()->with('success', 'User updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userHistory(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
|
// Fetch wallet transfers as history
|
||||||
|
$transfers = WalletTransfer::where('user_id', $user->id)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->limit(50)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return response()->json(['data' => $transfers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatIndex(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$aiEnabled = AppSetting::get('chat.ai_enabled', false);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Chat', [
|
||||||
|
'aiEnabled' => $aiEnabled
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleAi(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
$enabled = $request->input('enabled', false);
|
||||||
|
AppSetting::put('chat.ai_enabled', $enabled);
|
||||||
|
|
||||||
|
return back()->with('success', 'AI Chat ' . ($enabled ? 'enabled' : 'disabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteChatMessage($id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
// implement chat deletion - typically this would proxy to the backend API
|
||||||
|
// For now, we'll just mock it as success if we don't have direct DB access to it here
|
||||||
|
// (ChatMessage model exists locally, but messages might be on the upstream)
|
||||||
|
return back()->with('success', 'Message deleted (Mock)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatReportShow(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$report = ChatMessageReport::with([
|
||||||
|
'reporter:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||||
|
])->findOrFail($id);
|
||||||
|
|
||||||
|
$restrictionWith = fn ($q) => $q->withTrashed(false)->orderByDesc('created_at');
|
||||||
|
|
||||||
|
$senderUser = null;
|
||||||
|
if ($report->sender_id) {
|
||||||
|
$senderUser = User::with(['restrictions' => $restrictionWith])
|
||||||
|
->find($report->sender_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reporterUser = User::with(['restrictions' => $restrictionWith])
|
||||||
|
->find($report->reporter_id);
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ChatReportShow', [
|
||||||
|
'report' => $report,
|
||||||
|
'senderUser' => $senderUser,
|
||||||
|
'reporterUser' => $reporterUser,
|
||||||
|
'flash' => session('success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function punishFromChatReport(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$report = ChatMessageReport::findOrFail($id);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'type' => 'required|in:chat_ban,account_ban',
|
||||||
|
'reason' => 'required|string|max:300',
|
||||||
|
'hours' => 'nullable|integer|min:1', // null = permanent
|
||||||
|
]);
|
||||||
|
|
||||||
|
$targetId = $report->sender_id;
|
||||||
|
if (!$targetId) {
|
||||||
|
return back()->withErrors(['error' => 'Kein Nutzer mit dieser Nachricht verknüpft.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::findOrFail($targetId);
|
||||||
|
$admin = Auth::user();
|
||||||
|
$endsAt = $validated['hours'] ? now()->addHours($validated['hours']) : null;
|
||||||
|
|
||||||
|
UserRestriction::updateOrCreate(
|
||||||
|
['user_id' => $user->id, 'type' => $validated['type']],
|
||||||
|
[
|
||||||
|
'active' => true,
|
||||||
|
'reason' => $validated['reason'],
|
||||||
|
'notes' => "Via Chat-Report #$report->id",
|
||||||
|
'imposed_by' => $admin->id,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'ends_at' => $endsAt,
|
||||||
|
'source' => 'admin_panel',
|
||||||
|
'metadata' => ['report_id' => $report->id],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($validated['type'] === 'account_ban') {
|
||||||
|
$user->update([
|
||||||
|
'is_banned' => true,
|
||||||
|
'ban_reason' => $validated['reason'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$report->update(['status' => 'reviewed']);
|
||||||
|
|
||||||
|
return back()->with('success', 'Strafe wurde erfolgreich verhängt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chatReports(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$query = ChatMessageReport::with('reporter:id,username,email,avatar,avatar_url')
|
||||||
|
->orderByDesc('id');
|
||||||
|
|
||||||
|
$status = $request->input('status', 'pending');
|
||||||
|
if ($status && $status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = trim((string) $request->input('search', ''));
|
||||||
|
if ($search !== '') {
|
||||||
|
if (is_numeric($search)) {
|
||||||
|
$query->where('id', $search);
|
||||||
|
} else {
|
||||||
|
$q = '%' . $search . '%';
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->where('sender_username', 'like', $q)
|
||||||
|
->orWhereHas('reporter', fn($r) => $r->where('username', 'like', $q));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'pending' => ChatMessageReport::where('status', 'pending')->count(),
|
||||||
|
'reviewed' => ChatMessageReport::where('status', 'reviewed')->count(),
|
||||||
|
'dismissed' => ChatMessageReport::where('status', 'dismissed')->count(),
|
||||||
|
];
|
||||||
|
$stats['total'] = $stats['pending'] + $stats['reviewed'] + $stats['dismissed'];
|
||||||
|
|
||||||
|
$reports = $query->paginate(25)->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ChatReports', [
|
||||||
|
'reports' => $reports,
|
||||||
|
'filters' => $request->only(['status', 'search']),
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateChatReport(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'status' => 'required|in:pending,reviewed,dismissed',
|
||||||
|
'admin_note' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report = ChatMessageReport::findOrFail($id);
|
||||||
|
$report->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Report updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function liftRestriction(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$restriction = UserRestriction::findOrFail($id);
|
||||||
|
$restriction->update(['active' => false]);
|
||||||
|
|
||||||
|
if ($restriction->type === 'account_ban') {
|
||||||
|
User::where('id', $restriction->user_id)->update(['is_banned' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('success', 'Sperre aufgehoben.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function extendRestriction(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'hours' => 'required|integer|min:1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$restriction = UserRestriction::findOrFail($id);
|
||||||
|
$base = $restriction->ends_at && $restriction->ends_at->isFuture()
|
||||||
|
? $restriction->ends_at
|
||||||
|
: now();
|
||||||
|
|
||||||
|
$restriction->update(['ends_at' => $base->addHours($validated['hours'])]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Sperre verlängert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profileReports(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$query = ProfileReport::with([
|
||||||
|
'reporter:id,username,email,avatar,avatar_url',
|
||||||
|
'profile:id,username,email,avatar,avatar_url,role,vip_level,bio',
|
||||||
|
])->orderByDesc('id');
|
||||||
|
|
||||||
|
$status = $request->input('status', 'pending');
|
||||||
|
if ($status && $status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
$search = trim((string) $request->input('search', ''));
|
||||||
|
if ($search !== '') {
|
||||||
|
if (is_numeric($search)) {
|
||||||
|
$query->where('id', $search);
|
||||||
|
} else {
|
||||||
|
$q = '%' . $search . '%';
|
||||||
|
$query->where(function ($sub) use ($q) {
|
||||||
|
$sub->whereHas('reporter', fn($r) => $r->where('username', 'like', $q))
|
||||||
|
->orWhereHas('profile', fn($r) => $r->where('username', 'like', $q));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'pending' => ProfileReport::where('status', 'pending')->count(),
|
||||||
|
'reviewed' => ProfileReport::where('status', 'reviewed')->count(),
|
||||||
|
'dismissed' => ProfileReport::where('status', 'dismissed')->count(),
|
||||||
|
];
|
||||||
|
$stats['total'] = $stats['pending'] + $stats['reviewed'] + $stats['dismissed'];
|
||||||
|
|
||||||
|
$reports = $query->paginate(25)->withQueryString();
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ProfileReports', [
|
||||||
|
'reports' => $reports,
|
||||||
|
'filters' => $request->only(['status', 'search']),
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profileReportShow(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$report = ProfileReport::with([
|
||||||
|
'reporter:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||||
|
'profile:id,username,email,avatar,avatar_url,role,vip_level,is_banned,created_at',
|
||||||
|
])->findOrFail($id);
|
||||||
|
|
||||||
|
// Attach restrictions to both users
|
||||||
|
$reporterUser = null;
|
||||||
|
$profileUser = null;
|
||||||
|
|
||||||
|
if ($report->reporter_id) {
|
||||||
|
$reporterUser = User::with(['restrictions' => function ($q) {
|
||||||
|
$q->orderByDesc('id');
|
||||||
|
}])->find($report->reporter_id);
|
||||||
|
}
|
||||||
|
if ($report->profile_id) {
|
||||||
|
$profileUser = User::with(['restrictions' => function ($q) {
|
||||||
|
$q->orderByDesc('id');
|
||||||
|
}])->find($report->profile_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current live profile data from DB for comparison
|
||||||
|
$currentProfile = null;
|
||||||
|
if ($report->profile_id) {
|
||||||
|
$u = User::find($report->profile_id);
|
||||||
|
if ($u) {
|
||||||
|
$likesCount = ProfileLike::where('profile_id', $u->id)->count();
|
||||||
|
$comments = ProfileComment::with(['user:id,username,avatar'])
|
||||||
|
->where('profile_id', $u->id)
|
||||||
|
->latest()
|
||||||
|
->limit(15)
|
||||||
|
->get()
|
||||||
|
->map(fn($c) => [
|
||||||
|
'id' => $c->id,
|
||||||
|
'content' => $c->content,
|
||||||
|
'created_at' => $c->created_at,
|
||||||
|
'user' => [
|
||||||
|
'id' => $c->user->id,
|
||||||
|
'username' => $c->user->username,
|
||||||
|
'avatar' => $c->user->avatar,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentProfile = [
|
||||||
|
'id' => $u->id,
|
||||||
|
'username' => $u->username,
|
||||||
|
'avatar' => $u->avatar ?? $u->avatar_url,
|
||||||
|
'banner' => $u->banner,
|
||||||
|
'bio' => $u->bio,
|
||||||
|
'role' => $u->role ?? 'User',
|
||||||
|
'vip_level' => (int)($u->vip_level ?? 0),
|
||||||
|
'clan_tag' => $u->clan_tag,
|
||||||
|
'stats' => [
|
||||||
|
'wagered' => (float)($u->stats?->total_wagered ?? 0),
|
||||||
|
'wins' => (int)($u->stats?->total_wins ?? 0),
|
||||||
|
'biggest_win' => (float)($u->stats?->biggest_win ?? 0),
|
||||||
|
'likes_count' => $likesCount,
|
||||||
|
'join_date' => optional($u->created_at)->toDateString(),
|
||||||
|
],
|
||||||
|
'comments' => $comments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$screenshotUrl = $report->screenshot_path
|
||||||
|
? asset('storage/' . $report->screenshot_path)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return Inertia::render('Admin/ProfileReportShow', [
|
||||||
|
'report' => $report,
|
||||||
|
'reporterUser' => $reporterUser,
|
||||||
|
'profileUser' => $profileUser,
|
||||||
|
'screenshotUrl' => $screenshotUrl,
|
||||||
|
'currentProfile' => $currentProfile,
|
||||||
|
'flash' => session('success'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProfileReport(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'status' => 'required|in:pending,reviewed,dismissed',
|
||||||
|
'admin_note' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report = ProfileReport::findOrFail($id);
|
||||||
|
$report->update($validated);
|
||||||
|
|
||||||
|
return back()->with('success', 'Report updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function punishFromProfileReport(Request $request, $id)
|
||||||
|
{
|
||||||
|
$this->ensureAdmin();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'type' => 'required|in:chat_ban,account_ban',
|
||||||
|
'reason' => 'required|string|max:500',
|
||||||
|
'hours' => 'nullable|integer|min:1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$report = ProfileReport::findOrFail($id);
|
||||||
|
$target = User::findOrFail($report->profile_id);
|
||||||
|
|
||||||
|
$startsAt = now();
|
||||||
|
$endsAt = $data['hours'] ? now()->addHours($data['hours']) : null;
|
||||||
|
|
||||||
|
UserRestriction::create([
|
||||||
|
'user_id' => $target->id,
|
||||||
|
'type' => $data['type'],
|
||||||
|
'reason' => $data['reason'],
|
||||||
|
'active' => true,
|
||||||
|
'starts_at' => $startsAt,
|
||||||
|
'ends_at' => $endsAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data['type'] === 'account_ban') {
|
||||||
|
$target->update(['is_banned' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$report->update(['status' => 'reviewed']);
|
||||||
|
|
||||||
|
return back()->with('success', 'Strafe erfolgreich verhängt!');
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Http/Controllers/Auth/AvailabilityController.php
Normal file
69
app/Http/Controllers/Auth/AvailabilityController.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AvailabilityController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check availability for username or email during registration via upstream API.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$field = $request->query('field');
|
||||||
|
$value = (string) $request->query('value', '');
|
||||||
|
|
||||||
|
if (!in_array($field, ['username', 'email'], true)) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Unsupported field',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic format checks to reduce unnecessary upstream calls
|
||||||
|
if ($field === 'username' && mb_strlen($value) < 3) {
|
||||||
|
return response()->json(['ok' => true, 'available' => false, 'reason' => 'too_short']);
|
||||||
|
}
|
||||||
|
if ($field === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return response()->json(['ok' => true, 'available' => false, 'reason' => 'invalid_format']);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/api/auth/availability', [
|
||||||
|
'field' => $field,
|
||||||
|
'value' => $value,
|
||||||
|
], retry: true);
|
||||||
|
|
||||||
|
if ($res->successful()) {
|
||||||
|
$j = $res->json() ?: [];
|
||||||
|
// Normalize to { ok: true, available: bool, reason? }
|
||||||
|
$available = $j['available'] ?? $j['is_available'] ?? null;
|
||||||
|
if ($available !== null) {
|
||||||
|
$out = ['ok' => true, 'available' => (bool) $available];
|
||||||
|
if (isset($j['reason'])) {
|
||||||
|
$out['reason'] = $j['reason'];
|
||||||
|
}
|
||||||
|
return response()->json($out, 200);
|
||||||
|
}
|
||||||
|
// Fallback: pass-through
|
||||||
|
return response()->json($j, 200);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
|
||||||
|
class EmailVerificationCodeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle email verification via short code.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
'code' => ['required','string','regex:/^\d{6}$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->hasVerifiedEmail()) {
|
||||||
|
return redirect()->route('dashboard')->with('status', 'Email already verified.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'email_verify_code:'.$user->getKey();
|
||||||
|
$expected = Cache::get($cacheKey);
|
||||||
|
|
||||||
|
// Normalize submitted code to digits-only string
|
||||||
|
$submitted = (string) $request->input('code', '');
|
||||||
|
$submitted = preg_replace('/\D+/', '', $submitted ?? '');
|
||||||
|
|
||||||
|
if (!$expected || $expected !== $submitted) {
|
||||||
|
return back()->withErrors(['code' => 'Invalid or expired verification code.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as verified and clear the code
|
||||||
|
if ($user->markEmailAsVerified()) {
|
||||||
|
event(new Verified($user));
|
||||||
|
}
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
|
||||||
|
return redirect()->route('dashboard')->with('status', 'Email verified successfully.');
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/Http/Controllers/BetiXWebhookController.php
Normal file
171
app/Http/Controllers/BetiXWebhookController.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\GameBet;
|
||||||
|
use App\Models\OperatorSession;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\BetiXClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class BetiXWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function sessionEnded(Request $request, BetiXClient $client): \Illuminate\Http\Response
|
||||||
|
{
|
||||||
|
$rawBody = $request->getContent();
|
||||||
|
$sig = $request->header('X-Signature', '');
|
||||||
|
$secret = config('services.betix.webhook_secret', '');
|
||||||
|
|
||||||
|
// If a webhook secret is configured, verify the signature
|
||||||
|
if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) {
|
||||||
|
Log::warning('[BetiX] Webhook signature mismatch', ['ip' => $request->ip()]);
|
||||||
|
return response('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($rawBody, true);
|
||||||
|
if (!$payload || !isset($payload['session_token'])) {
|
||||||
|
return response('Bad Request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $payload['session_token'];
|
||||||
|
$playerId = $payload['player_id'] ?? null;
|
||||||
|
$endBalance = isset($payload['end_balance']) ? (float) $payload['end_balance'] : null;
|
||||||
|
$startBalance = isset($payload['start_balance']) ? (float) $payload['start_balance'] : null;
|
||||||
|
$game = $payload['game'] ?? null;
|
||||||
|
$rounds = $payload['rounds'] ?? 0;
|
||||||
|
|
||||||
|
$session = OperatorSession::where('session_token', $token)->first();
|
||||||
|
|
||||||
|
if (!$session) {
|
||||||
|
// Unknown session — could be a replay or a stale token, acknowledge anyway
|
||||||
|
Log::info('[BetiX] Webhook for unknown session', ['token' => $token]);
|
||||||
|
return response('OK', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($session->status !== 'active') {
|
||||||
|
// Already processed (idempotency)
|
||||||
|
return response('OK', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($session, $playerId, $endBalance, $startBalance, $rounds, $game) {
|
||||||
|
// Mark session as expired
|
||||||
|
$session->update([
|
||||||
|
'status' => 'expired',
|
||||||
|
'current_balance' => $endBalance ?? $session->current_balance,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($endBalance === null || $playerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply balance delta to user (safe even if other transactions happened mid-session)
|
||||||
|
$user = User::lockForUpdate()->find((int) $playerId);
|
||||||
|
if (!$user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sessionStart = $startBalance ?? (float) $session->start_balance;
|
||||||
|
$delta = $endBalance - $sessionStart; // positive = player won, negative = player lost
|
||||||
|
|
||||||
|
$newBalance = max(0, (float) $user->balance + $delta);
|
||||||
|
$user->balance = $newBalance;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[BetiX] Session ended | player=%s | game=%s | rounds=%d | delta=%+.4f BTX | new_balance=%.4f',
|
||||||
|
$playerId, $game ?? '?', $rounds, $delta, $newBalance
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
return response('OK', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/betix/round
|
||||||
|
*
|
||||||
|
* Called by the BetiX game server after each completed round.
|
||||||
|
* Updates the user's balance incrementally and logs to game_bets.
|
||||||
|
*
|
||||||
|
* Payload: { session_token, player_id, new_balance, currency, server_seed_hash, round }
|
||||||
|
*/
|
||||||
|
public function roundUpdate(Request $request, BetiXClient $client): \Illuminate\Http\Response
|
||||||
|
{
|
||||||
|
$rawBody = $request->getContent();
|
||||||
|
$sig = $request->header('X-Signature', '');
|
||||||
|
$secret = config('services.betix.webhook_secret', '');
|
||||||
|
|
||||||
|
if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) {
|
||||||
|
Log::warning('[BetiX] Round webhook signature mismatch', ['ip' => $request->ip()]);
|
||||||
|
return response('Unauthorized', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = json_decode($rawBody, true);
|
||||||
|
if (!$payload || !isset($payload['session_token'], $payload['player_id'], $payload['new_balance'])) {
|
||||||
|
return response('Bad Request', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $payload['session_token'];
|
||||||
|
$playerId = (int) $payload['player_id'];
|
||||||
|
$newBalance = (float) $payload['new_balance'];
|
||||||
|
$seedHash = $payload['server_seed_hash'] ?? null;
|
||||||
|
$roundNumber = $payload['round'] ?? null;
|
||||||
|
$currency = strtoupper($payload['currency'] ?? 'EUR');
|
||||||
|
|
||||||
|
$session = OperatorSession::where('session_token', $token)->first();
|
||||||
|
|
||||||
|
if (!$session || $session->status !== 'active') {
|
||||||
|
// Unknown or already-ended session — acknowledge without processing
|
||||||
|
return response('OK', 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($session, $playerId, $newBalance, $seedHash, $roundNumber, $currency) {
|
||||||
|
$oldSessionBalance = (float) $session->current_balance;
|
||||||
|
$delta = $newBalance - $oldSessionBalance; // positive = win, negative = loss
|
||||||
|
|
||||||
|
// Update session's running balance + optionally rotate the seed hash
|
||||||
|
$sessionUpdate = ['current_balance' => $newBalance];
|
||||||
|
if ($seedHash) {
|
||||||
|
$sessionUpdate['server_seed_hash'] = $seedHash;
|
||||||
|
}
|
||||||
|
$session->update($sessionUpdate);
|
||||||
|
|
||||||
|
// Apply balance change to user (row-locked for safety)
|
||||||
|
$user = User::lockForUpdate()->find($playerId);
|
||||||
|
if (!$user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBalanceBefore = (float) $user->balance;
|
||||||
|
$userBalanceAfter = max(0.0, $userBalanceBefore + $delta);
|
||||||
|
$user->balance = $userBalanceAfter;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Log the round to game_bets (wager = loss side, payout = win side)
|
||||||
|
GameBet::create([
|
||||||
|
'user_id' => $playerId,
|
||||||
|
'game_name' => $session->game_slug,
|
||||||
|
'wager_amount' => $delta < 0 ? abs($delta) : 0,
|
||||||
|
'payout_amount' => $delta > 0 ? $delta : 0,
|
||||||
|
'payout_multiplier'=> 0,
|
||||||
|
'currency' => $currency,
|
||||||
|
'session_token' => $session->session_token,
|
||||||
|
'round_number' => $roundNumber,
|
||||||
|
'server_seed_hash' => $seedHash,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info(sprintf(
|
||||||
|
'[BetiX] Round update | player=%d | game=%s | round=%s | delta=%+.4f | balance=%.4f→%.4f',
|
||||||
|
$playerId,
|
||||||
|
$session->game_slug,
|
||||||
|
$roundNumber ?? '?',
|
||||||
|
$delta,
|
||||||
|
$userBalanceBefore,
|
||||||
|
$userBalanceAfter
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
return response('OK', 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
156
app/Http/Controllers/BonusApiController.php
Normal file
156
app/Http/Controllers/BonusApiController.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class BonusApiController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
// Inline token check middleware (no alias registration needed)
|
||||||
|
$this->middleware(function ($request, $next) {
|
||||||
|
$provided = $this->extractToken($request);
|
||||||
|
$expected = config('services.bonus_api.token');
|
||||||
|
|
||||||
|
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractToken(Request $request): ?string
|
||||||
|
{
|
||||||
|
$auth = $request->header('Authorization');
|
||||||
|
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||||
|
return substr($auth, 7);
|
||||||
|
}
|
||||||
|
return $request->query('api_token'); // fallback: allow ?api_token=
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = [];
|
||||||
|
if ($status = $request->query('status')) {
|
||||||
|
$query['status'] = $status;
|
||||||
|
}
|
||||||
|
if ($request->boolean('active_only')) {
|
||||||
|
$query['active_only'] = 1;
|
||||||
|
}
|
||||||
|
$query['per_page'] = min(200, max(1, (int) $request->query('per_page', 50)));
|
||||||
|
|
||||||
|
$res = $this->client->get($request, '/bonuses', $query, retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Request $request, int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, "/bonuses/{$id}", [], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->validateData($request);
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, '/bonuses', $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: [], 201);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$data = $this->validateData($request, partial: true);
|
||||||
|
try {
|
||||||
|
$res = $this->client->patch($request, "/bonuses/{$id}", $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->delete($request, "/bonuses/{$id}");
|
||||||
|
if ($res->successful()) {
|
||||||
|
$body = $res->json();
|
||||||
|
return response()->json($body ?: ['message' => 'Deleted']);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateData(Request $request, bool $partial = false): array
|
||||||
|
{
|
||||||
|
$required = $partial ? 'sometimes' : 'required';
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'title' => [$required, 'string', 'max:255'],
|
||||||
|
'type' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'amount_value' => ['sometimes', 'nullable', 'numeric'],
|
||||||
|
'amount_unit' => ['sometimes', 'nullable', Rule::in(['USD','EUR','BTC','ETH','PERCENT','SPINS'])],
|
||||||
|
'min_deposit' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||||
|
'max_amount' => ['sometimes', 'nullable', 'numeric', 'min:0'],
|
||||||
|
'currency' => ['sometimes', 'nullable', 'string', 'max:16'],
|
||||||
|
'code' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'status' => ['sometimes', 'required', Rule::in(['draft','active','paused','expired'])],
|
||||||
|
'starts_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'expires_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||||
|
'rules' => ['sometimes', 'nullable', 'array'],
|
||||||
|
'description' => ['sometimes', 'nullable', 'string'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
|
// Normalize empty strings to null
|
||||||
|
foreach (['type','amount_unit','currency','code','description'] as $k) {
|
||||||
|
if (array_key_exists($k, $validated) && $validated[$k] === '') {
|
||||||
|
$validated[$k] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Controllers/BonusesController.php
Normal file
70
app/Http/Controllers/BonusesController.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class BonusesController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight JSON for the in-app Bonuses page.
|
||||||
|
* Authenticated web users only (route middleware handles auth+throttle).
|
||||||
|
*/
|
||||||
|
public function appIndex(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/bonuses/app', [
|
||||||
|
'limit' => min(100, (int) $request->query('limit', 50)),
|
||||||
|
], retry: true);
|
||||||
|
|
||||||
|
if ($res->successful()) {
|
||||||
|
$body = $res->json() ?: [];
|
||||||
|
$available = $body['available'] ?? $body['bonuses'] ?? [];
|
||||||
|
$active = $body['active'] ?? [];
|
||||||
|
$history = $body['history'] ?? [];
|
||||||
|
|
||||||
|
// Normalize available list to fields expected by UI (keep unknowns as-is)
|
||||||
|
$availableDto = [];
|
||||||
|
foreach ((array) $available as $b) {
|
||||||
|
if (!is_array($b)) continue;
|
||||||
|
$availableDto[] = [
|
||||||
|
'id' => $b['id'] ?? null,
|
||||||
|
'title' => $b['title'] ?? null,
|
||||||
|
'type' => $b['type'] ?? null,
|
||||||
|
'amount_value' => isset($b['amount_value']) ? (float) $b['amount_value'] : (isset($b['amount']) ? (float) $b['amount'] : null),
|
||||||
|
'amount_unit' => $b['amount_unit'] ?? $b['unit'] ?? null,
|
||||||
|
'min_deposit' => isset($b['min_deposit']) ? (float) $b['min_deposit'] : null,
|
||||||
|
'max_amount' => isset($b['max_amount']) ? (float) $b['max_amount'] : null,
|
||||||
|
'currency' => $b['currency'] ?? null,
|
||||||
|
'code' => $b['code'] ?? null,
|
||||||
|
'starts_at' => $b['starts_at'] ?? null,
|
||||||
|
'expires_at' => $b['expires_at'] ?? null,
|
||||||
|
'description' => $b['description'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'available' => $availableDto,
|
||||||
|
'active' => $active,
|
||||||
|
'history' => $history,
|
||||||
|
'now' => $body['now'] ?? now()->toIso8601String(),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/Http/Controllers/ChatController.php
Normal file
249
app/Http/Controllers/ChatController.php
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\ChatMessage;
|
||||||
|
use App\Models\ChatMessageReaction;
|
||||||
|
use App\Models\ChatMessageReport;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ChatController extends Controller
|
||||||
|
{
|
||||||
|
private function formatMessage(ChatMessage $m, int $authId = 0): array
|
||||||
|
{
|
||||||
|
$user = $m->user;
|
||||||
|
$reactionsAgg = [];
|
||||||
|
|
||||||
|
foreach ($m->reactions->groupBy('emoji') as $emoji => $group) {
|
||||||
|
$reactionsAgg[] = [
|
||||||
|
'emoji' => $emoji,
|
||||||
|
'count' => $group->count(),
|
||||||
|
'reactedByMe' => $group->contains('user_id', $authId),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$replyTo = null;
|
||||||
|
if ($m->reply_to_id && $m->replyTo) {
|
||||||
|
$replyTo = [
|
||||||
|
'id' => $m->replyTo->id,
|
||||||
|
'message' => $m->replyTo->message,
|
||||||
|
'user' => [
|
||||||
|
'id' => $m->replyTo->user?->id,
|
||||||
|
'username' => $m->replyTo->user?->username,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'user_id' => $m->user_id,
|
||||||
|
'message' => $m->is_deleted ? null : $m->message,
|
||||||
|
'is_deleted' => (bool) $m->is_deleted,
|
||||||
|
'deleted_by_user' => $m->is_deleted && $m->deletedByUser ? [
|
||||||
|
'id' => $m->deletedByUser->id,
|
||||||
|
'username' => $m->deletedByUser->username,
|
||||||
|
'role' => $m->deletedByUser->role ?? 'user',
|
||||||
|
] : null,
|
||||||
|
'reply_to_id' => $m->reply_to_id,
|
||||||
|
'reply_to' => $replyTo,
|
||||||
|
'created_at' => $m->created_at?->toIso8601String(),
|
||||||
|
'user' => [
|
||||||
|
'id' => $user?->id,
|
||||||
|
'username' => $user?->username,
|
||||||
|
'avatar_url' => $user?->avatar_url ?? $user?->avatar ?? null,
|
||||||
|
'role' => $user?->role ?? 'user',
|
||||||
|
'vip_level' => (int) ($user?->vip_level ?? 0),
|
||||||
|
'clan_tag' => $user?->clan_tag ?? null,
|
||||||
|
],
|
||||||
|
'reactions_agg' => $reactionsAgg,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List recent chat messages from local DB.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$limit = min(max((int) $request->query('limit', 50), 1), 200);
|
||||||
|
$afterId = $request->query('after_id');
|
||||||
|
$authId = (int) optional($request->user())->id;
|
||||||
|
|
||||||
|
$query = ChatMessage::with([
|
||||||
|
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||||
|
'reactions',
|
||||||
|
'replyTo:id,message,user_id',
|
||||||
|
'replyTo.user:id,username',
|
||||||
|
'deletedByUser:id,username,role',
|
||||||
|
])->orderBy('id', 'asc');
|
||||||
|
|
||||||
|
if ($afterId !== null) {
|
||||||
|
$query->where('id', '>', (int) $afterId);
|
||||||
|
} else {
|
||||||
|
// Return last N messages
|
||||||
|
$query = ChatMessage::with([
|
||||||
|
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||||
|
'reactions',
|
||||||
|
'replyTo:id,message,user_id',
|
||||||
|
'replyTo.user:id,username',
|
||||||
|
])->orderBy('id', 'desc')->limit($limit);
|
||||||
|
|
||||||
|
$messages = $query->get()->reverse()->values();
|
||||||
|
$formatted = $messages->map(fn ($m) => $this->formatMessage($m, $authId))->values()->all();
|
||||||
|
$lastId = $messages->last()?->id;
|
||||||
|
|
||||||
|
return response()->json(['data' => $formatted, 'last_id' => $lastId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = $query->limit($limit)->get();
|
||||||
|
$formatted = $messages->map(fn ($m) => $this->formatMessage($m, $authId))->values()->all();
|
||||||
|
$lastId = $messages->last()?->id;
|
||||||
|
|
||||||
|
return response()->json(['data' => $formatted, 'last_id' => $lastId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a new chat message in local DB.
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'message' => ['required', 'string', 'min:1', 'max:300'],
|
||||||
|
'reply_to_id' => ['nullable', 'integer', 'exists:chat_messages,id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check local chat ban
|
||||||
|
$chatBan = \App\Models\UserRestriction::where('user_id', $user->id)
|
||||||
|
->where('type', 'chat_ban')
|
||||||
|
->where('active', true)
|
||||||
|
->where(fn($q) => $q->whereNull('ends_at')->orWhere('ends_at', '>', now()))
|
||||||
|
->first();
|
||||||
|
if ($chatBan) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Du bist vom Chat gebannt.',
|
||||||
|
'type' => 'chat_ban',
|
||||||
|
'ends_at' => $chatBan->ends_at?->toIso8601String(),
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = ChatMessage::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'message' => $validated['message'],
|
||||||
|
'reply_to_id' => $validated['reply_to_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg->load([
|
||||||
|
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||||
|
'reactions',
|
||||||
|
'replyTo:id,message,user_id',
|
||||||
|
'replyTo.user:id,username',
|
||||||
|
'deletedByUser:id,username,role',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-delete a chat message (admin/mod only).
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$role = strtolower((string) $user->role);
|
||||||
|
if (!in_array($role, ['admin', 'moderator', 'mod'])) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = ChatMessage::findOrFail($id);
|
||||||
|
$msg->update(['is_deleted' => true, 'deleted_by' => $user->id]);
|
||||||
|
|
||||||
|
$msg->load([
|
||||||
|
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||||
|
'reactions',
|
||||||
|
'replyTo:id,message,user_id',
|
||||||
|
'replyTo.user:id,username',
|
||||||
|
'deletedByUser:id,username,role',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a reaction on a message.
|
||||||
|
*/
|
||||||
|
public function react(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'emoji' => ['required', 'string', 'max:8'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg = ChatMessage::findOrFail($id);
|
||||||
|
|
||||||
|
$existing = ChatMessageReaction::where([
|
||||||
|
'message_id' => $msg->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji' => $validated['emoji'],
|
||||||
|
])->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
$existing->delete();
|
||||||
|
} else {
|
||||||
|
ChatMessageReaction::create([
|
||||||
|
'message_id' => $msg->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'emoji' => $validated['emoji'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg->load([
|
||||||
|
'user:id,username,avatar,avatar_url,role,vip_level,clan_tag',
|
||||||
|
'reactions',
|
||||||
|
'replyTo:id,message,user_id',
|
||||||
|
'replyTo.user:id,username',
|
||||||
|
'deletedByUser:id,username,role',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatMessage($msg, (int) $user->id)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report a chat message.
|
||||||
|
*/
|
||||||
|
public function report(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'message_text' => ['required', 'string', 'max:500'],
|
||||||
|
'sender_id' => ['nullable', 'integer'],
|
||||||
|
'sender_username' => ['nullable', 'string', 'max:255'],
|
||||||
|
'reason' => ['nullable', 'string', 'max:255'],
|
||||||
|
'context_messages' => ['nullable', 'array'],
|
||||||
|
'context_messages.*.id' => ['nullable'],
|
||||||
|
'context_messages.*.message' => ['nullable', 'string', 'max:500'],
|
||||||
|
'context_messages.*.user' => ['nullable', 'array'],
|
||||||
|
'context_messages.*.created_at' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
ChatMessageReport::create([
|
||||||
|
'reporter_id' => $user->id,
|
||||||
|
'message_id' => (string) $id,
|
||||||
|
'message_text' => $validated['message_text'],
|
||||||
|
'sender_id' => $validated['sender_id'] ?? null,
|
||||||
|
'sender_username' => $validated['sender_username'] ?? null,
|
||||||
|
'reason' => $validated['reason'] ?? null,
|
||||||
|
'context_messages' => $validated['context_messages'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Reported.'], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Http/Controllers/Concerns/ProxiesBackend.php
Normal file
34
app/Http/Controllers/Concerns/ProxiesBackend.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
|
||||||
|
trait ProxiesBackend
|
||||||
|
{
|
||||||
|
protected function mapClientError(Response $res)
|
||||||
|
{
|
||||||
|
$msg = data_get($res->json(), 'message') ?? 'Invalid request';
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'client_error',
|
||||||
|
'message' => $msg,
|
||||||
|
], $res->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapServiceUnavailable(Response $res)
|
||||||
|
{
|
||||||
|
$msg = data_get($res->json(), 'message') ?? 'Internal server error';
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'service_unavailable',
|
||||||
|
'message' => $msg,
|
||||||
|
], 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function mapBadGateway(string $msg = 'API server not reachable')
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'bad_gateway',
|
||||||
|
'message' => $msg,
|
||||||
|
], 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
122
app/Http/Controllers/DepositController.php
Normal file
122
app/Http/Controllers/DepositController.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\CryptoPayment;
|
||||||
|
use App\Services\DepositService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class DepositController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DepositService $deposits)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /wallet/deposits/currencies — returns enabled currencies and limits (Live from API + Admin config)
|
||||||
|
*/
|
||||||
|
public function currencies(Request $request)
|
||||||
|
{
|
||||||
|
return response()->json($this->deposits->currenciesForUser(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /wallet/deposits — start a deposit via NOWPayments
|
||||||
|
* Body: { currency: string, amount: number|string }
|
||||||
|
*/
|
||||||
|
public function create(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'currency' => ['required','string','max:32'],
|
||||||
|
'amount' => ['required','numeric','min:0.00000001'], // USD/BTX in MVP
|
||||||
|
]);
|
||||||
|
|
||||||
|
$res = $this->deposits->startDeposit($user, strtoupper($data['currency']), (float) $data['amount']);
|
||||||
|
if (!$res || isset($res['error'])) {
|
||||||
|
$err = $res['error'] ?? 'unknown_error';
|
||||||
|
$payload = ['message' => $err];
|
||||||
|
if ($err === 'amount_out_of_bounds') {
|
||||||
|
$payload['min_usd'] = $res['min_usd'] ?? null;
|
||||||
|
$payload['max_usd'] = $res['max_usd'] ?? null;
|
||||||
|
}
|
||||||
|
return response()->json($payload, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($res, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /wallet/deposits/history — get user's payment history
|
||||||
|
*/
|
||||||
|
public function history(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$limit = min(50, max(1, (int) $request->query('limit', 10)));
|
||||||
|
$history = $this->deposits->getUserHistory($user, $limit);
|
||||||
|
|
||||||
|
return response()->json(['data' => $history], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /wallet/deposits/{order_id} — cancel a pending deposit
|
||||||
|
*/
|
||||||
|
public function cancel(string $orderId)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$cp = CryptoPayment::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$cp) {
|
||||||
|
return response()->json(['message' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow cancellation of pending deposits
|
||||||
|
if (!in_array($cp->status, ['waiting', 'new', 'confirming'])) {
|
||||||
|
return response()->json(['message' => 'This deposit can no longer be canceled.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cp->status = 'canceled';
|
||||||
|
$cp->save();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Deposit canceled.'], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /wallet/deposits/{order_id} — optional polling endpoint to see current status
|
||||||
|
*/
|
||||||
|
public function show(string $orderId)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$cp = CryptoPayment::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('order_id', $orderId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$cp) {
|
||||||
|
return response()->json(['message' => 'Not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'order_id' => $cp->order_id,
|
||||||
|
'invoice_id' => $cp->invoice_id,
|
||||||
|
'payment_id' => $cp->payment_id,
|
||||||
|
'status' => $cp->status,
|
||||||
|
'pay_currency' => $cp->pay_currency,
|
||||||
|
'price_amount' => (string) $cp->price_amount,
|
||||||
|
'credited_btx' => $cp->credited_btx !== null ? (string) $cp->credited_btx : null,
|
||||||
|
'credited_at' => $cp->credited_at?->toIso8601String(),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/Http/Controllers/DirectMessageController.php
Normal file
198
app/Http/Controllers/DirectMessageController.php
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\DirectMessage;
|
||||||
|
use App\Models\DirectMessageReport;
|
||||||
|
use App\Models\Friend;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class DirectMessageController extends Controller
|
||||||
|
{
|
||||||
|
private function formatUser(User $u): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $u->id,
|
||||||
|
'username' => $u->username,
|
||||||
|
'name' => $u->name,
|
||||||
|
'avatar' => $u->avatar,
|
||||||
|
'avatar_url' => $u->avatar_url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMessage(DirectMessage $m): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'message' => $m->is_deleted ? null : $m->message,
|
||||||
|
'sender_id' => $m->sender_id,
|
||||||
|
'is_deleted' => $m->is_deleted,
|
||||||
|
'is_read' => $m->is_read,
|
||||||
|
'created_at' => $m->created_at,
|
||||||
|
'user' => $m->sender ? $this->formatUser($m->sender) : null,
|
||||||
|
'reply_to' => $m->replyTo ? [
|
||||||
|
'id' => $m->replyTo->id,
|
||||||
|
'message' => $m->replyTo->is_deleted ? null : $m->replyTo->message,
|
||||||
|
'sender_id' => $m->replyTo->sender_id,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/dm/conversations
|
||||||
|
public function conversations()
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$sent = DirectMessage::where('sender_id', $me)->select('receiver_id as partner_id');
|
||||||
|
$received = DirectMessage::where('receiver_id', $me)->select('sender_id as partner_id');
|
||||||
|
|
||||||
|
$partnerIds = $sent->union($received)->pluck('partner_id')->unique()->values();
|
||||||
|
|
||||||
|
$conversations = $partnerIds->map(function ($pid) use ($me) {
|
||||||
|
$partner = User::select('id', 'username', 'name', 'avatar', 'avatar_url')->find($pid);
|
||||||
|
if (!$partner) return null;
|
||||||
|
|
||||||
|
$lastMsg = DirectMessage::where(function ($q) use ($me, $pid) {
|
||||||
|
$q->where('sender_id', $me)->where('receiver_id', $pid);
|
||||||
|
})->orWhere(function ($q) use ($me, $pid) {
|
||||||
|
$q->where('sender_id', $pid)->where('receiver_id', $me);
|
||||||
|
})->orderByDesc('created_at')->first();
|
||||||
|
|
||||||
|
$unread = DirectMessage::where('sender_id', $pid)
|
||||||
|
->where('receiver_id', $me)
|
||||||
|
->where('is_read', false)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'partner' => $this->formatUser($partner),
|
||||||
|
'last_message' => $lastMsg ? [
|
||||||
|
'id' => $lastMsg->id,
|
||||||
|
'message' => $lastMsg->is_deleted ? null : $lastMsg->message,
|
||||||
|
'sender_id' => $lastMsg->sender_id,
|
||||||
|
'created_at' => $lastMsg->created_at,
|
||||||
|
] : null,
|
||||||
|
'unread_count' => $unread,
|
||||||
|
];
|
||||||
|
})->filter()->sortByDesc(fn ($c) => optional($c['last_message'])['created_at'])->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $conversations]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/dm/{userId}
|
||||||
|
public function messages($userId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$msgs = DirectMessage::where(function ($q) use ($me, $userId) {
|
||||||
|
$q->where('sender_id', $me)->where('receiver_id', $userId);
|
||||||
|
})->orWhere(function ($q) use ($me, $userId) {
|
||||||
|
$q->where('sender_id', $userId)->where('receiver_id', $me);
|
||||||
|
})
|
||||||
|
->with(['sender:id,username,name,avatar,avatar_url', 'replyTo'])
|
||||||
|
->orderBy('created_at')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->map(fn ($m) => $this->formatMessage($m));
|
||||||
|
|
||||||
|
// Mark as read
|
||||||
|
DirectMessage::where('sender_id', $userId)
|
||||||
|
->where('receiver_id', $me)
|
||||||
|
->where('is_read', false)
|
||||||
|
->update(['is_read' => true]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $msgs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/dm/{userId}
|
||||||
|
public function send(Request $request, $userId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
$request->validate([
|
||||||
|
'message' => 'required|string|max:1000',
|
||||||
|
'reply_to_id' => 'nullable|integer|exists:direct_messages,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
User::findOrFail($userId);
|
||||||
|
|
||||||
|
$areFriends = Friend::where(function ($q) use ($me, $userId) {
|
||||||
|
$q->where('user_id', $me)->where('friend_id', $userId);
|
||||||
|
})->orWhere(function ($q) use ($me, $userId) {
|
||||||
|
$q->where('user_id', $userId)->where('friend_id', $me);
|
||||||
|
})->where('status', 'accepted')->exists();
|
||||||
|
|
||||||
|
if (!$areFriends) {
|
||||||
|
return response()->json(['error' => 'You must be friends to send messages.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = DirectMessage::create([
|
||||||
|
'sender_id' => $me,
|
||||||
|
'receiver_id' => $userId,
|
||||||
|
'message' => $request->message,
|
||||||
|
'reply_to_id' => $request->reply_to_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg->load(['sender:id,username,name,avatar,avatar_url', 'replyTo']);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatMessage($msg)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/dm/messages/{id}/report
|
||||||
|
public function report(Request $request, $messageId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
$request->validate([
|
||||||
|
'reason' => 'required|string|max:64',
|
||||||
|
'details' => 'nullable|string|max:500',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg = DirectMessage::where(function ($q) use ($me) {
|
||||||
|
$q->where('sender_id', $me)->orWhere('receiver_id', $me);
|
||||||
|
})->findOrFail($messageId);
|
||||||
|
|
||||||
|
DirectMessageReport::firstOrCreate(
|
||||||
|
['reporter_id' => $me, 'message_id' => $msg->id],
|
||||||
|
['reason' => $request->reason, 'details' => $request->details]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/friends
|
||||||
|
public function friends()
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$friends = Friend::where(function ($q) use ($me) {
|
||||||
|
$q->where('user_id', $me)->orWhere('friend_id', $me);
|
||||||
|
})->where('status', 'accepted')
|
||||||
|
->with(['user:id,username,name,avatar,avatar_url', 'friend:id,username,name,avatar,avatar_url'])
|
||||||
|
->get()
|
||||||
|
->map(function ($f) use ($me) {
|
||||||
|
$partner = $f->user_id === $me ? $f->friend : $f->user;
|
||||||
|
return $partner ? $this->formatUser($partner) : null;
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $friends]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/friends/requests
|
||||||
|
public function friendRequests()
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$requests = Friend::where('friend_id', $me)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->with('user:id,username,name,avatar,avatar_url')
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get()
|
||||||
|
->map(fn ($f) => [
|
||||||
|
'id' => $f->id,
|
||||||
|
'from' => $f->user ? $this->formatUser($f->user) : null,
|
||||||
|
'created_at' => $f->created_at,
|
||||||
|
])->filter(fn ($r) => $r['from'] !== null)->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $requests]);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
app/Http/Controllers/EmbedController.php
Normal file
130
app/Http/Controllers/EmbedController.php
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class EmbedController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show embedded game page.
|
||||||
|
*
|
||||||
|
* Requirement: For the Localhost provider (and generally safe),
|
||||||
|
* the URL must contain a `?mode=demo` or `?mode=real` query param
|
||||||
|
* so the correct mode is selected. If missing/invalid, we redirect
|
||||||
|
* to the same URL with a normalized `mode` parameter.
|
||||||
|
*/
|
||||||
|
public function show(Request $request, string $slug)
|
||||||
|
{
|
||||||
|
$rawMode = $request->query('mode');
|
||||||
|
$normalized = null;
|
||||||
|
if (is_string($rawMode)) {
|
||||||
|
$val = strtolower(trim($rawMode));
|
||||||
|
if (in_array($val, ['demo', 'real'], true)) {
|
||||||
|
$normalized = $val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If mode is missing or invalid → redirect to add/fix it (default demo)
|
||||||
|
if ($normalized === null) {
|
||||||
|
$normalized = 'demo';
|
||||||
|
$query = array_merge($request->query(), ['mode' => $normalized]);
|
||||||
|
$url = $request->url() . (empty($query) ? '' : ('?' . http_build_query($query)));
|
||||||
|
return redirect()->to($url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Localhost provider integration for specific games
|
||||||
|
$slugKey = strtolower($slug);
|
||||||
|
$supported = [
|
||||||
|
'dice' => 'dice',
|
||||||
|
'plinko' => 'plinko',
|
||||||
|
'mines' => 'mines',
|
||||||
|
];
|
||||||
|
$base = rtrim((string) config('games.providers.local.base_url', 'http://localhost:3001/games'), '/');
|
||||||
|
|
||||||
|
if (array_key_exists($slugKey, $supported)) {
|
||||||
|
$gamePath = $supported[$slugKey];
|
||||||
|
$src = $base . '/' . $gamePath . '/index.html?mode=' . urlencode($normalized);
|
||||||
|
|
||||||
|
$title = htmlspecialchars("Local Provider • {$slug} ({$normalized})", ENT_QUOTES, 'UTF-8');
|
||||||
|
$srcAttr = htmlspecialchars($src, ENT_QUOTES, 'UTF-8');
|
||||||
|
$slugJson = json_encode($slug, JSON_UNESCAPED_SLASHES);
|
||||||
|
$modeJson = json_encode($normalized);
|
||||||
|
$srcJson = json_encode($src, JSON_UNESCAPED_SLASHES);
|
||||||
|
$html = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{$title}</title>
|
||||||
|
<style>
|
||||||
|
html,body{margin:0;height:100%;background:#000}
|
||||||
|
.frame{position:fixed;inset:0;border:0;width:100%;height:100%;display:block;background:#000}
|
||||||
|
.bar{position:fixed;left:0;right:0;top:0;height:42px;background:#0b0b0b;border-bottom:1px solid #161616;display:flex;align-items:center;gap:10px;padding:0 12px;color:#d7d7d7;font:14px/1.2 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif}
|
||||||
|
.sp{flex:1}
|
||||||
|
.pill{background:#131313;border:1px solid #1f1f1f;border-radius:999px;padding:6px 10px;color:#bbb}
|
||||||
|
</style>
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; frame-src 'self' http://localhost:3001 http://127.0.0.1:3001; script-src 'self' 'unsafe-inline' http://localhost:3001 http://127.0.0.1:3001; connect-src *; img-src * data: blob:; style-src 'self' 'unsafe-inline' http://localhost:3001 http://127.0.0.1:3001; media-src *;" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bar">
|
||||||
|
<div>Localhost Provider</div>
|
||||||
|
<div class="pill">Slug: {$slugKey}</div>
|
||||||
|
<div class="pill">Mode: {$normalized}</div>
|
||||||
|
<div class="sp"></div>
|
||||||
|
<div style="opacity:.6">src: {$srcAttr}</div>
|
||||||
|
</div>
|
||||||
|
<iframe class="frame" src="{$srcAttr}" allowfullscreen></iframe>
|
||||||
|
<script>
|
||||||
|
try { window.parent && window.parent.postMessage({ type: 'casino.embed.ready', provider: 'localhost', slug: {$slugJson}, mode: {$modeJson}, src: {$srcJson} }, '*'); } catch (e) {}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
return new Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback minimal HTML placeholder for unknown slugs
|
||||||
|
$slugJson = json_encode($slug, JSON_UNESCAPED_SLASHES);
|
||||||
|
$modeJson = json_encode($normalized);
|
||||||
|
$html = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Game Embed • {$slug} ({$normalized})</title>
|
||||||
|
<style>
|
||||||
|
:root{color-scheme:dark light}
|
||||||
|
body{margin:0;background:#0b0b0b;color:#e8e8e8;font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif}
|
||||||
|
.wrap{min-height:100vh;display:grid;place-items:center}
|
||||||
|
.card{background:#0f0f0f;border:1px solid #1b1b1b;border-radius:12px;padding:24px;max-width:840px;width:92%}
|
||||||
|
h1{margin:0 0 8px;font-size:20px}
|
||||||
|
.muted{opacity:.7}
|
||||||
|
code{background:#121212;border:1px solid #1e1e1e;padding:.1em .35em;border-radius:6px}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="card">
|
||||||
|
<h1>Embedded Game (Fallback)</h1>
|
||||||
|
<p>Unbekannter Slug: <code>{$slug}</code></p>
|
||||||
|
<p>Mode: <code>{$normalized}</code></p>
|
||||||
|
<p class="muted">Unterstützte lokale Spiele: <code>dice</code>, <code>plinko</code>, <code>mines</code>. Bitte URL prüfen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
window.parent && window.parent.postMessage({ type: 'casino.embed.ready', slug: {$slugJson}, mode: {$modeJson} }, '*');
|
||||||
|
} catch {}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
return new Response($html, 200, ['Content-Type' => 'text/html; charset=utf-8']);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Controllers/FavoriteController.php
Normal file
68
app/Http/Controllers/FavoriteController.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\UserFavorite;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class FavoriteController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/favorites — list the authenticated user's favorite games
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
$favorites = UserFavorite::where('user_id', $user->id)
|
||||||
|
->orderByDesc('created_at')
|
||||||
|
->get(['game_slug', 'game_name', 'game_image', 'game_provider', 'created_at']);
|
||||||
|
|
||||||
|
return response()->json(['data' => $favorites]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/favorites — add a game to favorites
|
||||||
|
* Body: { slug, name?, image?, provider? }
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'slug' => ['required', 'string', 'max:128'],
|
||||||
|
'name' => ['nullable', 'string', 'max:255'],
|
||||||
|
'image' => ['nullable', 'string', 'max:512'],
|
||||||
|
'provider' => ['nullable', 'string', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$fav = UserFavorite::firstOrCreate(
|
||||||
|
['user_id' => $user->id, 'game_slug' => $data['slug']],
|
||||||
|
[
|
||||||
|
'game_name' => $data['name'] ?? null,
|
||||||
|
'game_image' => $data['image'] ?? null,
|
||||||
|
'game_provider' => $data['provider'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['data' => $fav], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/favorites/{slug} — remove from favorites
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, string $slug)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
UserFavorite::where('user_id', $user->id)
|
||||||
|
->where('game_slug', $slug)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Http/Controllers/FeedbackController.php
Normal file
105
app/Http/Controllers/FeedbackController.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\UserFeedback;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class FeedbackController extends Controller
|
||||||
|
{
|
||||||
|
public function showForm()
|
||||||
|
{
|
||||||
|
return Inertia::render('Feedback');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'category' => 'required|string|in:general,ux,mobile,feature,complaint',
|
||||||
|
'overall_rating' => 'nullable|integer|min:1|max:5',
|
||||||
|
'ux_rating' => 'nullable|integer|min:1|max:5',
|
||||||
|
'comfort_rating' => 'nullable|integer|min:1|max:5',
|
||||||
|
'mobile_rating' => 'nullable|integer|min:1|max:5',
|
||||||
|
'uses_mobile' => 'nullable|boolean',
|
||||||
|
'nps_score' => 'nullable|integer|min:1|max:10',
|
||||||
|
'ux_comment' => 'nullable|string|max:2000',
|
||||||
|
'mobile_comment' => 'nullable|string|max:2000',
|
||||||
|
'feature_request' => 'nullable|string|max:2000',
|
||||||
|
'improvements' => 'nullable|string|max:2000',
|
||||||
|
'general_comment' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data['user_id'] = auth()->id();
|
||||||
|
|
||||||
|
UserFeedback::create($data);
|
||||||
|
|
||||||
|
return back()->with('success', 'Danke für dein Feedback!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function adminIndex(Request $request)
|
||||||
|
{
|
||||||
|
$status = $request->input('status', 'new');
|
||||||
|
$search = trim($request->input('search', ''));
|
||||||
|
|
||||||
|
$query = UserFeedback::with('user')
|
||||||
|
->orderByDesc('created_at');
|
||||||
|
|
||||||
|
if ($status && $status !== 'all') {
|
||||||
|
$query->where('status', $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($search !== '') {
|
||||||
|
if (is_numeric($search)) {
|
||||||
|
$query->where('id', (int) $search);
|
||||||
|
} else {
|
||||||
|
$query->whereHas('user', function ($q) use ($search) {
|
||||||
|
$q->where('username', 'like', '%' . $search . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$feedbacks = $query->paginate(25)->withQueryString();
|
||||||
|
|
||||||
|
$stats = [
|
||||||
|
'total' => UserFeedback::count(),
|
||||||
|
'new' => UserFeedback::where('status', 'new')->count(),
|
||||||
|
'read' => UserFeedback::where('status', 'read')->count(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Admin/Feedback', [
|
||||||
|
'feedbacks' => $feedbacks,
|
||||||
|
'filters' => ['status' => $status, 'search' => $search],
|
||||||
|
'stats' => $stats,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminShow(int $id)
|
||||||
|
{
|
||||||
|
$feedback = UserFeedback::with('user')->findOrFail($id);
|
||||||
|
|
||||||
|
if ($feedback->status === 'new') {
|
||||||
|
$feedback->update(['status' => 'read']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Admin/FeedbackShow', [
|
||||||
|
'feedback' => $feedback,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function adminUpdate(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$feedback = UserFeedback::findOrFail($id);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'status' => 'nullable|in:new,read',
|
||||||
|
'admin_note' => 'nullable|string|max:2000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$feedback->update(array_filter($data, fn ($v) => $v !== null));
|
||||||
|
|
||||||
|
return back()->with('success', 'Gespeichert.');
|
||||||
|
}
|
||||||
|
}
|
||||||
220
app/Http/Controllers/GuildActionController.php
Normal file
220
app/Http/Controllers/GuildActionController.php
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Guild;
|
||||||
|
use App\Models\GuildMember;
|
||||||
|
use App\Models\GuildMessage;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class GuildActionController extends Controller
|
||||||
|
{
|
||||||
|
private function generateInviteCode(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$code = strtoupper(Str::random(8));
|
||||||
|
} while (Guild::where('invite_code', $code)->exists());
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
if (GuildMember::where('user_id', $user->id)->exists()) {
|
||||||
|
return response()->json(['message' => 'Du bist bereits in einer Gilde.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'min:3', 'max:40', 'unique:guilds,name'],
|
||||||
|
'tag' => ['required', 'string', 'min:2', 'max:6', 'regex:/^[A-Za-z0-9]{2,6}$/'],
|
||||||
|
'description' => ['nullable', 'string', 'max:500'],
|
||||||
|
'logo' => ['nullable', 'file', 'image', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tag = strtoupper($validated['tag']);
|
||||||
|
if (Guild::where('tag', $tag)->exists()) {
|
||||||
|
return response()->json(['errors' => ['tag' => ['Dieses Tag ist bereits vergeben.']]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$logoUrl = null;
|
||||||
|
if ($request->hasFile('logo') && $request->file('logo')->isValid()) {
|
||||||
|
$path = $request->file('logo')->store('guild-logos', 'public');
|
||||||
|
$logoUrl = Storage::url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guild = Guild::create([
|
||||||
|
'name' => $validated['name'],
|
||||||
|
'tag' => $tag,
|
||||||
|
'logo_url' => $logoUrl,
|
||||||
|
'description' => isset($validated['description']) ? strip_tags($validated['description']) : null,
|
||||||
|
'owner_id' => $user->id,
|
||||||
|
'invite_code' => $this->generateInviteCode(),
|
||||||
|
'points' => 0,
|
||||||
|
'members_count' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
GuildMember::create([
|
||||||
|
'guild_id' => $guild->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'role' => 'owner',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
GuildMessage::create([
|
||||||
|
'guild_id' => $guild->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => 'system',
|
||||||
|
'message' => 'hat die Gilde gegründet 🎉',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'data' => $guild], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function join(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
if (GuildMember::where('user_id', $user->id)->exists()) {
|
||||||
|
return response()->json(['message' => 'Du bist bereits in einer Gilde.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'invite_code' => ['required', 'string', 'min:4', 'max:16'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guild = Guild::where('invite_code', strtoupper($data['invite_code']))->first();
|
||||||
|
if (!$guild) {
|
||||||
|
return response()->json(['message' => 'Ungültiger Einladungscode.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
GuildMember::create([
|
||||||
|
'guild_id' => $guild->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'role' => 'member',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guild->increment('members_count');
|
||||||
|
|
||||||
|
GuildMessage::create([
|
||||||
|
'guild_id' => $guild->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'type' => 'system',
|
||||||
|
'message' => 'ist der Gilde beigetreten 👋',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function leave(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$member = GuildMember::where('user_id', $user->id)->first();
|
||||||
|
if (!$member) return response()->json(['message' => 'Du bist in keiner Gilde.'], 422);
|
||||||
|
|
||||||
|
// Owner cannot leave — must disband or transfer first
|
||||||
|
if ($member->role === 'owner') {
|
||||||
|
return response()->json(['message' => 'Als Owner kannst du die Gilde nicht verlassen. Lösche die Gilde oder übertrage den Besitz.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guildId = $member->guild_id;
|
||||||
|
$member->delete();
|
||||||
|
Guild::where('id', $guildId)->decrement('members_count');
|
||||||
|
|
||||||
|
return response()->json(['success' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function kick(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => ['required', 'integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$actor = GuildMember::where('user_id', $user->id)->first();
|
||||||
|
if (!$actor || !in_array($actor->role, ['owner', 'officer'])) {
|
||||||
|
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = GuildMember::where('user_id', $data['user_id'])
|
||||||
|
->where('guild_id', $actor->guild_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$target) return response()->json(['message' => 'Mitglied nicht gefunden.'], 404);
|
||||||
|
if ($target->role === 'owner') return response()->json(['message' => 'Den Owner kannst du nicht kicken.'], 422);
|
||||||
|
|
||||||
|
$target->delete();
|
||||||
|
Guild::where('id', $actor->guild_id)->decrement('members_count');
|
||||||
|
|
||||||
|
return response()->json(['success' => true], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$member = GuildMember::where('user_id', $user->id)->first();
|
||||||
|
if (!$member || !in_array($member->role, ['owner', 'officer'])) {
|
||||||
|
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'description' => ['sometimes', 'nullable', 'string', 'max:500'],
|
||||||
|
'name' => ['sometimes', 'string', 'min:3', 'max:40'],
|
||||||
|
'tag' => ['sometimes', 'string', 'min:2', 'max:6', 'regex:/^[A-Za-z0-9]{2,6}$/'],
|
||||||
|
'logo' => ['sometimes', 'nullable', 'file', 'image', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guild = Guild::findOrFail($member->guild_id);
|
||||||
|
|
||||||
|
if ($request->hasFile('logo') && $request->file('logo')->isValid()) {
|
||||||
|
// Delete old logo if stored locally
|
||||||
|
if ($guild->logo_url && str_contains($guild->logo_url, '/storage/')) {
|
||||||
|
$oldPath = str_replace('/storage/', 'public/', $guild->logo_url);
|
||||||
|
Storage::delete($oldPath);
|
||||||
|
}
|
||||||
|
$path = $request->file('logo')->store('guild-logos', 'public');
|
||||||
|
$guild->logo_url = Storage::url($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($validated['description'])) {
|
||||||
|
$guild->description = strip_tags($validated['description']);
|
||||||
|
}
|
||||||
|
if (isset($validated['name'])) {
|
||||||
|
$guild->name = $validated['name'];
|
||||||
|
}
|
||||||
|
if (isset($validated['tag'])) {
|
||||||
|
$guild->tag = strtoupper($validated['tag']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guild->save();
|
||||||
|
|
||||||
|
return response()->json(['success' => true, 'data' => $guild], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateInvite(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) return response()->json(['message' => 'Unauthenticated'], 401);
|
||||||
|
|
||||||
|
$member = GuildMember::where('user_id', $user->id)->first();
|
||||||
|
if (!$member || !in_array($member->role, ['owner', 'officer'])) {
|
||||||
|
return response()->json(['message' => 'Keine Berechtigung.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guild = Guild::findOrFail($member->guild_id);
|
||||||
|
$guild->update(['invite_code' => $this->generateInviteCode()]);
|
||||||
|
|
||||||
|
return response()->json(['invite_code' => $guild->invite_code], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Http/Controllers/GuildChatController.php
Normal file
127
app/Http/Controllers/GuildChatController.php
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\GuildMember;
|
||||||
|
use App\Models\GuildMessage;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class GuildChatController extends Controller
|
||||||
|
{
|
||||||
|
private function formatMessage(GuildMessage $m): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $m->id,
|
||||||
|
'type' => $m->type ?? 'message',
|
||||||
|
'message' => $m->is_deleted ? null : $m->message,
|
||||||
|
'user_id' => $m->user_id,
|
||||||
|
'is_deleted' => $m->is_deleted,
|
||||||
|
'created_at' => $m->created_at,
|
||||||
|
'user' => $m->user ? [
|
||||||
|
'id' => $m->user->id,
|
||||||
|
'username' => $m->user->username,
|
||||||
|
'avatar' => $m->user->avatar ?? $m->user->avatar_url,
|
||||||
|
] : null,
|
||||||
|
'reply_to' => $m->replyTo ? [
|
||||||
|
'id' => $m->replyTo->id,
|
||||||
|
'message' => $m->replyTo->is_deleted ? null : $m->replyTo->message,
|
||||||
|
'user_id' => $m->replyTo->user_id,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/guild-chat/me
|
||||||
|
public function myGuild()
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
$member = GuildMember::where('user_id', $me)->with('guild')->first();
|
||||||
|
|
||||||
|
if (!$member || !$member->guild) {
|
||||||
|
return response()->json(['data' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guild = $member->guild;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'id' => $guild->id,
|
||||||
|
'name' => $guild->name,
|
||||||
|
'tag' => $guild->tag,
|
||||||
|
'logo_url' => $guild->logo_url,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/guild-chat/{guildId}
|
||||||
|
public function messages($guildId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||||
|
if (!$isMember) {
|
||||||
|
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msgs = GuildMessage::where('guild_id', $guildId)
|
||||||
|
->with(['user:id,username,avatar,avatar_url', 'replyTo'])
|
||||||
|
->orderBy('created_at')
|
||||||
|
->limit(100)
|
||||||
|
->get()
|
||||||
|
->map(fn ($m) => $this->formatMessage($m));
|
||||||
|
|
||||||
|
return response()->json(['data' => $msgs]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/guild-chat/{guildId}
|
||||||
|
public function send(Request $request, $guildId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||||
|
if (!$isMember) {
|
||||||
|
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'message' => 'required|string|max:1000',
|
||||||
|
'reply_to_id' => 'nullable|integer|exists:guild_messages,id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg = GuildMessage::create([
|
||||||
|
'guild_id' => $guildId,
|
||||||
|
'user_id' => $me,
|
||||||
|
'type' => 'message',
|
||||||
|
'message' => $request->message,
|
||||||
|
'reply_to_id' => $request->reply_to_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$msg->load(['user:id,username,avatar,avatar_url', 'replyTo']);
|
||||||
|
|
||||||
|
return response()->json(['data' => $this->formatMessage($msg)], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/guild-chat/{guildId}/members
|
||||||
|
public function members($guildId)
|
||||||
|
{
|
||||||
|
$me = Auth::id();
|
||||||
|
|
||||||
|
$isMember = GuildMember::where('guild_id', $guildId)->where('user_id', $me)->exists();
|
||||||
|
if (!$isMember) {
|
||||||
|
return response()->json(['error' => 'Not a guild member.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$members = GuildMember::where('guild_id', $guildId)
|
||||||
|
->with('user:id,username,avatar,avatar_url')
|
||||||
|
->orderByRaw("FIELD(role, 'owner', 'officer', 'member')")
|
||||||
|
->get()
|
||||||
|
->map(fn ($m) => [
|
||||||
|
'id' => $m->user->id,
|
||||||
|
'username' => $m->user->username,
|
||||||
|
'avatar' => $m->user->avatar ?? $m->user->avatar_url,
|
||||||
|
'role' => $m->role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $members]);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
app/Http/Controllers/GuildController.php
Normal file
101
app/Http/Controllers/GuildController.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Guild;
|
||||||
|
use App\Models\GuildMember;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class GuildController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
return Inertia::render('guilds/Index', [
|
||||||
|
'guild' => null,
|
||||||
|
'myRole' => null,
|
||||||
|
'canManage' => false,
|
||||||
|
'invite' => null,
|
||||||
|
'topPlayers' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$member = GuildMember::where('user_id', $user->id)->first();
|
||||||
|
|
||||||
|
if (!$member) {
|
||||||
|
return Inertia::render('guilds/Index', [
|
||||||
|
'guild' => null,
|
||||||
|
'myRole' => null,
|
||||||
|
'canManage' => false,
|
||||||
|
'invite' => null,
|
||||||
|
'topPlayers' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$guild = Guild::with(['owner:id,username,avatar_url', 'members:id,username,avatar_url'])
|
||||||
|
->findOrFail($member->guild_id);
|
||||||
|
|
||||||
|
$myRole = $member->role;
|
||||||
|
$canManage = in_array($myRole, ['owner', 'officer']);
|
||||||
|
|
||||||
|
// Build member list with pivot data
|
||||||
|
$memberRows = GuildMember::where('guild_id', $guild->id)
|
||||||
|
->with('user:id,username,avatar_url')
|
||||||
|
->get()
|
||||||
|
->map(fn ($m) => [
|
||||||
|
'id' => $m->user_id,
|
||||||
|
'username' => $m->user?->username,
|
||||||
|
'avatar_url'=> $m->user?->avatar_url,
|
||||||
|
'role' => $m->role,
|
||||||
|
'joined_at' => $m->joined_at?->toIso8601String(),
|
||||||
|
'wagered' => (float) ($m->wagered ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$guildData = [
|
||||||
|
'id' => $guild->id,
|
||||||
|
'name' => $guild->name,
|
||||||
|
'tag' => $guild->tag,
|
||||||
|
'logo_url' => $guild->logo_url,
|
||||||
|
'description' => $guild->description,
|
||||||
|
'points' => $guild->points,
|
||||||
|
'members_count'=> $guild->members_count,
|
||||||
|
'owner' => [
|
||||||
|
'id' => $guild->owner->id,
|
||||||
|
'username' => $guild->owner->username,
|
||||||
|
'avatar_url'=> $guild->owner->avatar_url,
|
||||||
|
],
|
||||||
|
'members' => $memberRows,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('guilds/Index', [
|
||||||
|
'guild' => $guildData,
|
||||||
|
'myRole' => $myRole,
|
||||||
|
'canManage' => $canManage,
|
||||||
|
'invite' => $canManage ? $guild->invite_code : null,
|
||||||
|
'topPlayers' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function top(Request $request)
|
||||||
|
{
|
||||||
|
$guilds = Guild::with('owner:id,username')
|
||||||
|
->orderByDesc('points')
|
||||||
|
->orderByDesc('members_count')
|
||||||
|
->limit(50)
|
||||||
|
->get()
|
||||||
|
->map(fn ($g) => [
|
||||||
|
'id' => $g->id,
|
||||||
|
'name' => $g->name,
|
||||||
|
'tag' => $g->tag,
|
||||||
|
'logo_url' => $g->logo_url,
|
||||||
|
'points' => $g->points,
|
||||||
|
'members_count'=> $g->members_count,
|
||||||
|
'owner' => ['id' => $g->owner?->id, 'username' => $g->owner?->username],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return Inertia::render('guilds/Top', ['guilds' => $guilds]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Http/Controllers/LocaleController.php
Normal file
48
app/Http/Controllers/LocaleController.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
|
class LocaleController extends Controller
|
||||||
|
{
|
||||||
|
/** @var array<string> */
|
||||||
|
private array $available = [
|
||||||
|
'en','de','es','pt_BR','tr','pl',
|
||||||
|
'fr','it','ru','uk','vi','id','zh_CN','ja','ko','sv','no','fi','nl',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function set(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'locale' => ['required','string','max:8'],
|
||||||
|
]);
|
||||||
|
$code = $this->normalize($data['locale']);
|
||||||
|
if (!in_array($code, $this->available, true)) {
|
||||||
|
return response()->json(['message' => 'Unsupported locale.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist to session and cookie (1 year)
|
||||||
|
$request->session()->put('locale', $code);
|
||||||
|
Cookie::queue(cookie('locale', $code, 60 * 24 * 365));
|
||||||
|
|
||||||
|
// Update user preference if logged in (no local DB writes in gateway)
|
||||||
|
if ($user = $request->user()) {
|
||||||
|
if (($user->preferred_locale ?? null) !== $code) {
|
||||||
|
// Defer persistence to external API if needed; here we only keep session/cookie.
|
||||||
|
// Optionally enqueue an event to sync upstream.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(string $code): string
|
||||||
|
{
|
||||||
|
$code = str_replace([' ', '-'], ['','_'], trim($code));
|
||||||
|
if (strtolower($code) === 'pt_br') return 'pt_BR';
|
||||||
|
if (strtolower($code) === 'zh_cn') return 'zh_CN';
|
||||||
|
return strtolower($code);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Controllers/NowPaymentsWebhookController.php
Normal file
27
app/Http/Controllers/NowPaymentsWebhookController.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\DepositService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class NowPaymentsWebhookController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly DepositService $deposits)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/webhooks/nowpayments — public IPN endpoint
|
||||||
|
* - CSRF should be disabled via api middleware group
|
||||||
|
* - Validates HMAC signature and processes deposit crediting
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request)
|
||||||
|
{
|
||||||
|
$out = $this->deposits->handleIpn($request);
|
||||||
|
return response()->json([
|
||||||
|
'ok' => (bool) ($out['ok'] ?? false),
|
||||||
|
'message' => $out['message'] ?? 'ok',
|
||||||
|
], (int) ($out['status'] ?? 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
112
app/Http/Controllers/OperatorController.php
Normal file
112
app/Http/Controllers/OperatorController.php
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\OperatorCasino;
|
||||||
|
use App\Models\OperatorSession;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OperatorController extends Controller
|
||||||
|
{
|
||||||
|
// Supported game slugs
|
||||||
|
private const VALID_GAMES = ['dice', 'crash', 'mines', 'plinko'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /operator/games
|
||||||
|
*
|
||||||
|
* Returns the full game catalog with thumbnail URLs and launch paths.
|
||||||
|
*/
|
||||||
|
public function games(Request $request)
|
||||||
|
{
|
||||||
|
$baseUrl = rtrim((string) config('games.game_base_url', config('app.url')), '/');
|
||||||
|
|
||||||
|
$games = collect(config('games.catalog', []))->map(fn ($g) => array_merge($g, [
|
||||||
|
'thumbnail_url' => "{$baseUrl}/assets/games/{$g['slug']}.png",
|
||||||
|
'launch_path' => "/{$g['slug']}",
|
||||||
|
]));
|
||||||
|
|
||||||
|
return response()->json(['games' => $games]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /operator/launch
|
||||||
|
*
|
||||||
|
* Creates a new operator game session and returns the launch URL.
|
||||||
|
*/
|
||||||
|
public function launch(Request $request)
|
||||||
|
{
|
||||||
|
/** @var OperatorCasino $casino */
|
||||||
|
$casino = $request->attributes->get('operator_casino');
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'player_id' => ['required', 'string', 'max:255'],
|
||||||
|
'balance' => ['required', 'numeric', 'min:0'],
|
||||||
|
'currency' => ['required', 'string', 'size:3'],
|
||||||
|
'game' => ['required', 'string', 'in:' . implode(',', self::VALID_GAMES)],
|
||||||
|
// license_key is consumed by middleware; allow it in the body without failing validation
|
||||||
|
'license_key' => ['sometimes', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate server seed for provably-fair and store it encrypted
|
||||||
|
$serverSeed = bin2hex(random_bytes(32));
|
||||||
|
$serverSeedHash = hash('sha256', $serverSeed);
|
||||||
|
|
||||||
|
$token = (string) Str::uuid();
|
||||||
|
$expiresAt = now()->addHours(4);
|
||||||
|
|
||||||
|
OperatorSession::create([
|
||||||
|
'session_token' => $token,
|
||||||
|
'operator_casino_id' => $casino->id,
|
||||||
|
'player_id' => $data['player_id'],
|
||||||
|
'game_slug' => $data['game'],
|
||||||
|
'currency' => strtoupper($data['currency']),
|
||||||
|
'start_balance' => $data['balance'],
|
||||||
|
'current_balance' => $data['balance'],
|
||||||
|
'server_seed' => encrypt($serverSeed),
|
||||||
|
'server_seed_hash' => $serverSeedHash,
|
||||||
|
'status' => 'active',
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$baseUrl = rtrim((string) config('games.game_base_url', config('app.url')), '/');
|
||||||
|
$launchUrl = "{$baseUrl}/{$data['game']}?session={$token}";
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_token' => $token,
|
||||||
|
'launch_url' => $launchUrl,
|
||||||
|
'server_seed_hash' => $serverSeedHash,
|
||||||
|
'expires_at' => $expiresAt->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /operator/session/{token}
|
||||||
|
*
|
||||||
|
* Returns the current state of a session — including the final balance delta.
|
||||||
|
* The casino should call this after the player's session ends.
|
||||||
|
*/
|
||||||
|
public function session(Request $request, string $token)
|
||||||
|
{
|
||||||
|
/** @var OperatorCasino $casino */
|
||||||
|
$casino = $request->attributes->get('operator_casino');
|
||||||
|
|
||||||
|
$session = OperatorSession::where('session_token', $token)
|
||||||
|
->where('operator_casino_id', $casino->id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$session->expireIfNeeded();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'session_token' => $session->session_token,
|
||||||
|
'player_id' => $session->player_id,
|
||||||
|
'game' => $session->game_slug,
|
||||||
|
'currency' => $session->currency,
|
||||||
|
'start_balance' => (float) $session->start_balance,
|
||||||
|
'current_balance' => (float) $session->current_balance,
|
||||||
|
'balance_delta' => round((float) $session->current_balance - (float) $session->start_balance, 4),
|
||||||
|
'status' => $session->status,
|
||||||
|
'expires_at' => $session->expires_at->toIso8601String(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Controllers/PromoController.php
Normal file
51
app/Http/Controllers/PromoController.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class PromoController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a promo code for the authenticated user via external API.
|
||||||
|
* Request: { code: string }
|
||||||
|
*/
|
||||||
|
public function apply(Request $request)
|
||||||
|
{
|
||||||
|
$data = Validator::make($request->all(), [
|
||||||
|
'code' => ['required','string','max:64'],
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$code = strtoupper(trim($data['code']));
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, '/promos/apply', [ 'code' => $code ]);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$body = $res->json() ?: [];
|
||||||
|
// Backward compatibility: ensure a message key exists
|
||||||
|
if (!isset($body['message'])) {
|
||||||
|
$body['message'] = 'Promo applied successfully.';
|
||||||
|
}
|
||||||
|
// PromoControllerTest expects { success: true, message: '...' }
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => $body['message']
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Controllers/RecentlyPlayedController.php
Normal file
35
app/Http/Controllers/RecentlyPlayedController.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\GameBet;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class RecentlyPlayedController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* GET /api/recently-played
|
||||||
|
* Returns up to 8 distinct recently played games for the authenticated user.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
// Use a subquery to get the latest bet per game_name
|
||||||
|
$games = GameBet::where('user_id', $user->id)
|
||||||
|
->select('game_name', \Illuminate\Support\Facades\DB::raw('MAX(created_at) as last_played_at'))
|
||||||
|
->groupBy('game_name')
|
||||||
|
->orderByDesc('last_played_at')
|
||||||
|
->limit(8)
|
||||||
|
->get()
|
||||||
|
->map(fn($row) => [
|
||||||
|
'game_name' => $row->game_name,
|
||||||
|
'slug' => strtolower(preg_replace('/[^a-z0-9]+/i', '-', $row->game_name)),
|
||||||
|
'last_played_at' => $row->last_played_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['data' => $games]);
|
||||||
|
}
|
||||||
|
}
|
||||||
136
app/Http/Controllers/Settings/KycController.php
Normal file
136
app/Http/Controllers/Settings/KycController.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class KycController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show KYC center page with user's documents (from upstream)
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$docs = [];
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/kyc/documents', [], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$j = $res->json() ?: [];
|
||||||
|
$docs = $j['data'] ?? $j['documents'] ?? $j;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// ignore; page can still render and show empty state
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('settings/Kyc', [
|
||||||
|
'documents' => $docs,
|
||||||
|
'accepted' => [
|
||||||
|
'identity' => ['passport','driver_license','id_card','other'],
|
||||||
|
'address' => ['bank_statement','utility_bill','other'],
|
||||||
|
'payment' => ['online_banking','other'],
|
||||||
|
],
|
||||||
|
'maxUploadMb' => 15,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a new KYC document via upstream (multipart)
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'category' => ['required', Rule::in(['identity','address','payment'])],
|
||||||
|
'type' => ['required', Rule::in(['passport','driver_license','id_card','bank_statement','utility_bill','online_banking','other'])],
|
||||||
|
'file' => ['required','file','max:15360', 'mimetypes:image/jpeg,image/png,image/webp,application/pdf'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->postMultipart($request, '/kyc/documents', [
|
||||||
|
'category' => $validated['category'],
|
||||||
|
'type' => $validated['type'],
|
||||||
|
], $request->file('file'), 'file');
|
||||||
|
|
||||||
|
if ($res->successful()) {
|
||||||
|
return back()->with('status', 'Document uploaded');
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||||
|
return back()->withErrors(['kyc' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a KYC document via upstream
|
||||||
|
*/
|
||||||
|
public function destroy(Request $request, int $docId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->delete($request, "/kyc/documents/{$docId}");
|
||||||
|
if ($res->successful()) {
|
||||||
|
return back()->with('status', 'Document deleted');
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||||
|
return back()->withErrors(['kyc' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a document via upstream: prefer redirect to signed URL if provided
|
||||||
|
*/
|
||||||
|
public function download(Request $request, int $docId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, "/kyc/documents/{$docId}/download", [], retry: false);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$j = $res->json();
|
||||||
|
$url = $j['url'] ?? null;
|
||||||
|
if ($url) {
|
||||||
|
return redirect()->away($url);
|
||||||
|
}
|
||||||
|
// If upstream responds with binary directly, just passthrough headers/body
|
||||||
|
$content = $res->body();
|
||||||
|
$headers = [
|
||||||
|
'Content-Type' => $res->header('Content-Type', 'application/octet-stream'),
|
||||||
|
];
|
||||||
|
return response($content, 200, $headers);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||||
|
return back()->withErrors(['kyc' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['kyc' => 'Service temporarily unavailable']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['kyc' => 'API server not reachable']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
32
app/Http/Controllers/Settings/PasswordController.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class PasswordController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user's password settings page.
|
||||||
|
*/
|
||||||
|
public function edit(): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('settings/Password');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's password.
|
||||||
|
*/
|
||||||
|
public function update(PasswordUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->update([
|
||||||
|
'password' => $request->password,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
60
app/Http/Controllers/Settings/ProfileController.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||||
|
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Show the user's profile settings page.
|
||||||
|
*/
|
||||||
|
public function edit(Request $request): Response
|
||||||
|
{
|
||||||
|
return Inertia::render('settings/Profile', [
|
||||||
|
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||||
|
'status' => $request->session()->get('status'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's profile information.
|
||||||
|
*/
|
||||||
|
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$request->user()->fill($request->validated());
|
||||||
|
|
||||||
|
if ($request->user()->isDirty('email')) {
|
||||||
|
$request->user()->email_verified_at = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->user()->save();
|
||||||
|
|
||||||
|
return to_route('profile.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the user's profile.
|
||||||
|
*/
|
||||||
|
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
Auth::logout();
|
||||||
|
|
||||||
|
$user->delete();
|
||||||
|
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
|
||||||
|
return redirect('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Controllers/Settings/SecurityController.php
Normal file
70
app/Http/Controllers/Settings/SecurityController.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class SecurityController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Render the Security center page.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
// Provide a light payload; sessions loaded via separate endpoint
|
||||||
|
return Inertia::render('settings/Security', [
|
||||||
|
'twoFactorEnabled' => (bool) optional($request->user())->hasEnabledTwoFactorAuthentication(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List active sessions for the current user (from database sessions table).
|
||||||
|
*/
|
||||||
|
public function sessions(Request $request)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$rows = DB::table('sessions')
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->orderByDesc('last_activity')
|
||||||
|
->limit(100)
|
||||||
|
->get(['id', 'ip_address', 'user_agent', 'last_activity']);
|
||||||
|
|
||||||
|
// Format response
|
||||||
|
$data = $rows->map(function ($r) use ($request) {
|
||||||
|
$isCurrent = $request->session()->getId() === $r->id;
|
||||||
|
return [
|
||||||
|
'id' => $r->id,
|
||||||
|
'ip' => $r->ip_address,
|
||||||
|
'user_agent' => $r->user_agent,
|
||||||
|
'last_activity' => $r->last_activity,
|
||||||
|
'current' => $isCurrent,
|
||||||
|
];
|
||||||
|
})->values();
|
||||||
|
|
||||||
|
return response()->json(['data' => $data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific session by ID (current user's session only)
|
||||||
|
*/
|
||||||
|
public function revoke(Request $request, string $id)
|
||||||
|
{
|
||||||
|
$userId = Auth::id();
|
||||||
|
$session = DB::table('sessions')->where('id', $id)->first();
|
||||||
|
if (! $session || $session->user_id != $userId) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
// Prevent revoking current session via this endpoint to avoid lockouts
|
||||||
|
if ($request->session()->getId() === $id) {
|
||||||
|
return response()->json(['message' => 'Cannot revoke current session via API.'], 422);
|
||||||
|
}
|
||||||
|
DB::table('sessions')->where('id', $id)->delete();
|
||||||
|
return response()->json(['message' => 'Session revoked']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||||
|
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||||
|
use Illuminate\Routing\Controllers\Middleware;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
class TwoFactorAuthenticationController extends Controller implements HasMiddleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the middleware that should be assigned to the controller.
|
||||||
|
*/
|
||||||
|
public static function middleware(): array
|
||||||
|
{
|
||||||
|
return Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword')
|
||||||
|
? [new Middleware('password.confirm', only: ['show'])]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the user's two-factor authentication settings page.
|
||||||
|
*/
|
||||||
|
public function show(TwoFactorAuthenticationRequest $request): Response
|
||||||
|
{
|
||||||
|
$request->ensureStateIsValid();
|
||||||
|
|
||||||
|
return Inertia::render('settings/TwoFactor', [
|
||||||
|
'twoFactorEnabled' => $request->user()->hasEnabledTwoFactorAuthentication(),
|
||||||
|
'requiresConfirmation' => Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
377
app/Http/Controllers/SocialController.php
Normal file
377
app/Http/Controllers/SocialController.php
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Friend;
|
||||||
|
use App\Models\ProfileComment;
|
||||||
|
use App\Models\ProfileLike;
|
||||||
|
use App\Models\ProfileReport;
|
||||||
|
use App\Models\Tip;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class SocialController extends Controller
|
||||||
|
{
|
||||||
|
// Profile page
|
||||||
|
public function show(string $username)
|
||||||
|
{
|
||||||
|
$user = User::query()
|
||||||
|
->where('username', $username)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
abort(404, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$auth = Auth::user();
|
||||||
|
$authId = $auth?->id;
|
||||||
|
|
||||||
|
$likesCount = ProfileLike::where('profile_id', $user->id)->count();
|
||||||
|
$hasLiked = $authId ? ProfileLike::where('profile_id', $user->id)->where('user_id', $authId)->exists() : false;
|
||||||
|
|
||||||
|
$comments = ProfileComment::with(['user:id,username,avatar'])
|
||||||
|
->where('profile_id', $user->id)
|
||||||
|
->latest()
|
||||||
|
->limit(20)
|
||||||
|
->get()
|
||||||
|
->map(function ($c) {
|
||||||
|
return [
|
||||||
|
'id' => $c->id,
|
||||||
|
'user' => [
|
||||||
|
'id' => $c->user->id,
|
||||||
|
'username' => $c->user->username,
|
||||||
|
'avatar' => $c->user->avatar,
|
||||||
|
],
|
||||||
|
'content' => $c->content,
|
||||||
|
'created_at' => $c->created_at,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Friendship flags
|
||||||
|
$isFriend = false;
|
||||||
|
$isPending = false; // I sent them a request (outgoing)
|
||||||
|
$theyRequestedMe = false; // They sent me a request (incoming — I can accept/decline)
|
||||||
|
$friendRowId = null;
|
||||||
|
if ($authId) {
|
||||||
|
$friendRow = Friend::where(function ($q) use ($authId, $user) {
|
||||||
|
$q->where('user_id', $authId)->where('friend_id', $user->id);
|
||||||
|
})->orWhere(function ($q) use ($authId, $user) {
|
||||||
|
$q->where('user_id', $user->id)->where('friend_id', $authId);
|
||||||
|
})->first();
|
||||||
|
if ($friendRow) {
|
||||||
|
$isFriend = $friendRow->status === 'accepted';
|
||||||
|
$isPending = $friendRow->status === 'pending' && $friendRow->user_id == $authId;
|
||||||
|
$theyRequestedMe = $friendRow->status === 'pending' && $friendRow->user_id == $user->id;
|
||||||
|
$friendRowId = $friendRow->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile = [
|
||||||
|
'id' => $user->id,
|
||||||
|
'username' => $user->username,
|
||||||
|
'avatar' => $user->avatar ?? $user->avatar_url,
|
||||||
|
'banner' => $user->banner,
|
||||||
|
'bio' => $user->bio,
|
||||||
|
'vip_level' => (int) ($user->vip_level ?? 0),
|
||||||
|
'role' => $user->role ?? 'User',
|
||||||
|
'clan_tag' => $user->clan_tag,
|
||||||
|
'stats' => [
|
||||||
|
'wagered' => (float) ($user->stats?->total_wagered ?? 0),
|
||||||
|
'wins' => (int) ($user->stats?->total_wins ?? 0),
|
||||||
|
'losses' => null,
|
||||||
|
'biggest_win' => (float) ($user->stats?->biggest_win ?? 0),
|
||||||
|
'biggest_win_game' => $user->stats?->biggest_win_game ?? null,
|
||||||
|
'join_date' => optional($user->created_at)->toDateString(),
|
||||||
|
'likes_count' => $likesCount,
|
||||||
|
],
|
||||||
|
'best_wins' => [],
|
||||||
|
'comments' => $comments,
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Social/Profile', [
|
||||||
|
'profile' => $profile,
|
||||||
|
'isOwnProfile' => $authId ? ((int)$authId === (int)$user->id) : false,
|
||||||
|
'isFriend' => $isFriend,
|
||||||
|
'isPending' => $isPending,
|
||||||
|
'theyRequestedMe' => $theyRequestedMe,
|
||||||
|
'friendRowId' => $friendRowId,
|
||||||
|
'hasLiked' => $hasLiked,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update own profile
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
'bio' => 'nullable|string|max:160',
|
||||||
|
'avatar' => 'nullable|string',
|
||||||
|
'banner' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->fill([
|
||||||
|
'is_public' => (bool) ($data['is_public'] ?? $user->is_public),
|
||||||
|
'bio' => $data['bio'] ?? $user->bio,
|
||||||
|
'avatar' => $data['avatar'] ?? $user->avatar,
|
||||||
|
'banner' => $data['banner'] ?? $user->banner,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return back()->with('success', 'Profile updated.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload avatar or banner
|
||||||
|
public function uploadImage(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'type' => 'required|in:avatar,banner',
|
||||||
|
'file' => 'required|file|mimes:jpeg,jpg,png,gif,webp,bmp|max:5120',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = $request->file('file');
|
||||||
|
$type = $request->input('type');
|
||||||
|
$path = $file->store("profile/{$type}s", 'public');
|
||||||
|
$url = Storage::disk('public')->url($path);
|
||||||
|
|
||||||
|
if ($type === 'banner') {
|
||||||
|
$user->banner = $url;
|
||||||
|
} else {
|
||||||
|
$user->avatar = $url;
|
||||||
|
}
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json(['url' => $url], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle like on a profile
|
||||||
|
public function like($id)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$target = User::findOrFail($id);
|
||||||
|
if ($target->id === $me->id) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
$like = ProfileLike::where('user_id', $me->id)->where('profile_id', $target->id)->first();
|
||||||
|
if ($like) {
|
||||||
|
$like->delete();
|
||||||
|
} else {
|
||||||
|
ProfileLike::create(['user_id' => $me->id, 'profile_id' => $target->id]);
|
||||||
|
}
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a comment to a profile
|
||||||
|
public function comment(Request $request, $id)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$data = $request->validate(['content' => 'required|string|max:500']);
|
||||||
|
$target = User::findOrFail($id);
|
||||||
|
ProfileComment::create([
|
||||||
|
'user_id' => $me->id,
|
||||||
|
'profile_id' => $target->id,
|
||||||
|
'content' => $data['content'],
|
||||||
|
]);
|
||||||
|
return back()->with('success', 'Comment posted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report a profile
|
||||||
|
public function report(Request $request, $id)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'reason' => 'required|string',
|
||||||
|
'details' => 'nullable|string',
|
||||||
|
'snapshot' => 'nullable|array',
|
||||||
|
'screenshot' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
$target = User::findOrFail($id);
|
||||||
|
|
||||||
|
$screenshotPath = null;
|
||||||
|
if (!empty($data['screenshot'])) {
|
||||||
|
$base64 = $data['screenshot'];
|
||||||
|
// Strip data URI prefix if present
|
||||||
|
if (str_contains($base64, ',')) {
|
||||||
|
$base64 = explode(',', $base64, 2)[1];
|
||||||
|
}
|
||||||
|
$decoded = base64_decode($base64, true);
|
||||||
|
if ($decoded !== false) {
|
||||||
|
$filename = 'reports/profile-screenshots/' . $target->id . '_' . time() . '.png';
|
||||||
|
Storage::disk('public')->put($filename, $decoded);
|
||||||
|
$screenshotPath = $filename;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProfileReport::create([
|
||||||
|
'reporter_id' => $me->id,
|
||||||
|
'profile_id' => $target->id,
|
||||||
|
'reason' => $data['reason'],
|
||||||
|
'details' => $data['details'] ?? null,
|
||||||
|
'snapshot' => $data['snapshot'] ?? null,
|
||||||
|
'screenshot_path' => $screenshotPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json(['success' => true]);
|
||||||
|
}
|
||||||
|
return back()->with('success', 'Profile reported.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// User search
|
||||||
|
public function search(Request $request)
|
||||||
|
{
|
||||||
|
$q = (string) $request->input('q', '');
|
||||||
|
if (strlen($q) < 1) return response()->json([]);
|
||||||
|
|
||||||
|
$users = User::query()
|
||||||
|
->where('username', 'like', "%" . str_replace(['%','_'], ['\\%','\\_'], $q) . "%")
|
||||||
|
->orWhere('id', '=', $q)
|
||||||
|
->orderBy('username')
|
||||||
|
->limit(10)
|
||||||
|
->get(['id', 'username', 'avatar', 'avatar_url', 'vip_level', 'balance']);
|
||||||
|
|
||||||
|
$out = $users->map(function ($u) {
|
||||||
|
return [
|
||||||
|
'id' => $u->id,
|
||||||
|
'username' => $u->username,
|
||||||
|
'avatar' => $u->avatar ?? $u->avatar_url,
|
||||||
|
'avatar_url' => $u->avatar_url ?? $u->avatar,
|
||||||
|
'vip_level' => (int) ($u->vip_level ?? 0),
|
||||||
|
'stats' => [
|
||||||
|
'wager' => 0, // Placeholder
|
||||||
|
'wins' => 0, // Placeholder
|
||||||
|
'balance' => $u->balance
|
||||||
|
]
|
||||||
|
];
|
||||||
|
});
|
||||||
|
return response()->json($out->all(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a tip (balance transfer) and record it
|
||||||
|
public function tip(Request $request, $id)
|
||||||
|
{
|
||||||
|
$sender = Auth::user();
|
||||||
|
abort_unless($sender, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'currency' => 'required|string|max:10',
|
||||||
|
'amount' => 'required|numeric|min:0.00000001',
|
||||||
|
'note' => 'nullable|string|max:140',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ((int)$id === (int)$sender->id) {
|
||||||
|
return back()->withErrors(['amount' => 'You cannot tip yourself.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$receiver = User::findOrFail($id);
|
||||||
|
$amount = (float) $data['amount'];
|
||||||
|
|
||||||
|
if ($sender->balance < $amount) {
|
||||||
|
return back()->withErrors(['amount' => 'Insufficient balance']);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($sender, $receiver, $amount, $data) {
|
||||||
|
// Simple balance transfer using users.balance
|
||||||
|
$sender->balance = (float) $sender->balance - $amount;
|
||||||
|
$receiver->balance = (float) $receiver->balance + $amount;
|
||||||
|
$sender->save();
|
||||||
|
$receiver->save();
|
||||||
|
|
||||||
|
Tip::create([
|
||||||
|
'from_user_id' => $sender->id,
|
||||||
|
'to_user_id' => $receiver->id,
|
||||||
|
'currency' => strtoupper($data['currency']),
|
||||||
|
'amount' => $amount,
|
||||||
|
'note' => $data['note'] ?? null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return back()->with('success', 'Tip sent successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Friends ---
|
||||||
|
public function requestFriend(Request $request)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$data = $request->validate([
|
||||||
|
'user_id' => 'required|integer',
|
||||||
|
]);
|
||||||
|
$targetId = (int) $data['user_id'];
|
||||||
|
if ($targetId === (int)$me->id) {
|
||||||
|
return back()->withErrors(['friend' => 'You cannot add yourself.']);
|
||||||
|
}
|
||||||
|
$target = User::findOrFail($targetId);
|
||||||
|
|
||||||
|
$existing = Friend::where('user_id', $me->id)->where('friend_id', $target->id)->first();
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing->status === 'pending') {
|
||||||
|
return back()->with('success', 'Friend request already sent.');
|
||||||
|
}
|
||||||
|
if ($existing->status === 'accepted') {
|
||||||
|
return back()->with('success', 'You are already friends.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Friend::updateOrCreate([
|
||||||
|
'user_id' => $me->id,
|
||||||
|
'friend_id' => $target->id,
|
||||||
|
], [ 'status' => 'pending' ]);
|
||||||
|
|
||||||
|
return back()->with('success', 'Friend request sent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function acceptFriend($id)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$requestRow = Friend::where('user_id', $id)->where('friend_id', $me->id)->where('status', 'pending')->first();
|
||||||
|
if (!$requestRow) {
|
||||||
|
return back()->withErrors(['friend' => 'Request not found.']);
|
||||||
|
}
|
||||||
|
DB::transaction(function () use ($requestRow, $me, $id) {
|
||||||
|
$requestRow->status = 'accepted';
|
||||||
|
$requestRow->save();
|
||||||
|
// Ensure reciprocal row
|
||||||
|
Friend::updateOrCreate([
|
||||||
|
'user_id' => $me->id,
|
||||||
|
'friend_id' => (int) $id,
|
||||||
|
], [ 'status' => 'accepted' ]);
|
||||||
|
});
|
||||||
|
return back()->with('success', 'Friend request accepted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function declineFriend($id)
|
||||||
|
{
|
||||||
|
$me = Auth::user();
|
||||||
|
abort_unless($me, 403);
|
||||||
|
$requestRow = Friend::where('user_id', $id)->where('friend_id', $me->id)->where('status', 'pending')->first();
|
||||||
|
if (!$requestRow) {
|
||||||
|
return back()->withErrors(['friend' => 'Request not found.']);
|
||||||
|
}
|
||||||
|
$requestRow->delete();
|
||||||
|
return back()->with('success', 'Friend request declined.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hub()
|
||||||
|
{
|
||||||
|
return Inertia::render('Social/Hub');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function me()
|
||||||
|
{
|
||||||
|
return Inertia::render('Social/Settings', [
|
||||||
|
'user' => Auth::user(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
400
app/Http/Controllers/SupportChatController.php
Normal file
400
app/Http/Controllers/SupportChatController.php
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class SupportChatController extends Controller
|
||||||
|
{
|
||||||
|
private const SESSION_KEY = 'support_chat';
|
||||||
|
|
||||||
|
private function ensureEnabled()
|
||||||
|
{
|
||||||
|
// Use config/env directly to avoid cache/DB dependency if possible,
|
||||||
|
// or assume cache is file-based (which is fine).
|
||||||
|
$flag = cache()->get('support_chat_enabled');
|
||||||
|
$enabled = is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
||||||
|
if (!$enabled) {
|
||||||
|
abort(503, 'Support chat is currently unavailable.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isChatEnabled(): bool
|
||||||
|
{
|
||||||
|
$flag = cache()->get('support_chat_enabled');
|
||||||
|
return is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sessionState(Request $request): array
|
||||||
|
{
|
||||||
|
$state = $request->session()->get(self::SESSION_KEY, [
|
||||||
|
'thread_id' => null,
|
||||||
|
'status' => 'new',
|
||||||
|
'topic' => null,
|
||||||
|
'messages' => [],
|
||||||
|
'data_access_granted' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!empty($state['thread_id'])) {
|
||||||
|
$cached = cache()->get('support_threads:'.$state['thread_id']);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
$state['status'] = $cached['status'] ?? $state['status'];
|
||||||
|
foreach ($cached['messages'] ?? [] as $msg) {
|
||||||
|
if (($msg['sender'] ?? '') === 'agent') {
|
||||||
|
$state['status'] = 'agent';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count($cached['messages'] ?? []) > count($state['messages'])) {
|
||||||
|
$state['messages'] = $cached['messages'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveState(Request $request, array $state): void
|
||||||
|
{
|
||||||
|
$state['messages'] = array_slice($state['messages'], -100);
|
||||||
|
$state['updated_at'] = now()->toIso8601String();
|
||||||
|
$request->session()->put(self::SESSION_KEY, $state);
|
||||||
|
$this->persistThread($request, $state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function persistThread(Request $request, array $state): void
|
||||||
|
{
|
||||||
|
if (empty($state['thread_id'])) return;
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Construct user data safely without assuming local DB columns exist if Auth is mocked
|
||||||
|
$userData = null;
|
||||||
|
if ($user) {
|
||||||
|
$userData = [
|
||||||
|
'id' => $user->id ?? 'guest',
|
||||||
|
'username' => $user->username ?? $user->name ?? 'Guest',
|
||||||
|
'email' => $user->email ?? '',
|
||||||
|
'avatar_url' => $user->avatar_url ?? $user->profile_photo_url ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = [
|
||||||
|
'id' => $state['thread_id'],
|
||||||
|
'status' => $state['status'] ?? 'new',
|
||||||
|
'topic' => $state['topic'] ?? null,
|
||||||
|
'user' => $userData,
|
||||||
|
'messages' => $state['messages'] ?? [],
|
||||||
|
'updated_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Cache is file/redis based, so this is fine (no SQL DB)
|
||||||
|
cache()->put('support_threads:'.$state['thread_id'], $record, now()->addDay());
|
||||||
|
|
||||||
|
$index = cache()->get('support_threads_index', []);
|
||||||
|
$found = false;
|
||||||
|
foreach ($index as &$row) {
|
||||||
|
if (($row['id'] ?? null) === $record['id']) {
|
||||||
|
$row['updated_at'] = $record['updated_at'];
|
||||||
|
$row['status'] = $record['status'];
|
||||||
|
$row['topic'] = $record['topic'];
|
||||||
|
$row['user'] = $record['user'];
|
||||||
|
$found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$found) {
|
||||||
|
$index[] = [
|
||||||
|
'id' => $record['id'],
|
||||||
|
'updated_at' => $record['updated_at'],
|
||||||
|
'status' => $record['status'],
|
||||||
|
'topic' => $record['topic'],
|
||||||
|
'user' => $record['user'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
cache()->put('support_threads_index', $index, now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Request $request)
|
||||||
|
{
|
||||||
|
// ensureEnabled removed here to allow handoff logic
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
$data = $request->validate(['topic' => 'nullable|string|max:60']);
|
||||||
|
$enabled = $this->isChatEnabled();
|
||||||
|
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
if (!$state['thread_id']) {
|
||||||
|
$state['thread_id'] = (string) Str::uuid();
|
||||||
|
$state['status'] = $enabled ? 'ai' : 'handoff';
|
||||||
|
$state['topic'] = $data['topic'] ?? null;
|
||||||
|
$state['messages'] = [];
|
||||||
|
$state['data_access_granted'] = false;
|
||||||
|
$state['user'] = [
|
||||||
|
'id' => $user->id,
|
||||||
|
'username' => $user->username ?? $user->name,
|
||||||
|
'email' => $user->email
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!empty($data['topic'])) {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => 'Thema gewählt: ' . $data['topic'], 'at' => now()->toIso8601String()];
|
||||||
|
|
||||||
|
if ($enabled) {
|
||||||
|
$aiReply = $this->askOllama($state);
|
||||||
|
if ($aiReply) {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||||
|
} else {
|
||||||
|
// Ollama offline or returned nothing — fall back to human handoff
|
||||||
|
$state['status'] = 'handoff';
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Unser KI-Assistent ist gerade nicht erreichbar. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Der KI-Assistent ist derzeit inaktiv. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless(Auth::check(), 401);
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
|
||||||
|
if (!empty($state['thread_id'])) {
|
||||||
|
cache()->forget('support_threads:'.$state['thread_id']);
|
||||||
|
$index = cache()->get('support_threads_index', []);
|
||||||
|
$index = array_filter($index, fn($item) => $item['id'] !== $state['thread_id']);
|
||||||
|
cache()->put('support_threads_index', array_values($index), now()->addDay());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->session()->forget(self::SESSION_KEY);
|
||||||
|
|
||||||
|
return response()->json(['thread_id' => null, 'status' => 'closed', 'topic' => null, 'messages' => []]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
abort_unless($user, 401);
|
||||||
|
|
||||||
|
$data = $request->validate(['text' => 'required|string|min:1|max:1000']);
|
||||||
|
$text = trim($data['text']);
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
$enabled = $this->isChatEnabled();
|
||||||
|
|
||||||
|
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
||||||
|
return response()->json(['error' => 'No active chat thread.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => $text, 'at' => now()->toIso8601String()];
|
||||||
|
|
||||||
|
if (!$enabled) {
|
||||||
|
$lastMsg = collect($state['messages'])->where('sender', 'system')->last();
|
||||||
|
if (!$lastMsg || !Str::contains($lastMsg['body'], 'Mitarbeiter')) {
|
||||||
|
$state['status'] = 'handoff';
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Deine Nachricht wurde empfangen. Bitte warte auf einen Mitarbeiter.', 'at' => now()->toIso8601String()];
|
||||||
|
}
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Str::of(Str::lower($text))->contains(['ja', 'yes', 'erlaubt', 'okay', 'ok', 'zugriff'])) {
|
||||||
|
$lastSystemMsg = collect($state['messages'])->where('sender', 'system')->last();
|
||||||
|
if ($lastSystemMsg && Str::contains($lastSystemMsg['body'], 'zugreifen')) {
|
||||||
|
$state['data_access_granted'] = true;
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Zugriff erlaubt. Ich sehe mir deine Daten an...', 'at' => now()->toIso8601String()];
|
||||||
|
$aiReply = $this->askOllama($state);
|
||||||
|
if ($aiReply) {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||||
|
}
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Str::contains($text, ['🛑', ':stop:', 'STOP'])) {
|
||||||
|
$state['status'] = 'stopped';
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Verstanden. Soll ein Mitarbeiter übernehmen?', 'at' => now()->toIso8601String()];
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state['status'] === 'stopped' && Str::of(Str::lower($text))->contains(['ja', 'yes', 'y'])) {
|
||||||
|
return $this->handoff($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($state['status'], ['handoff', 'agent'])) {
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
$aiReply = $this->askOllama($state);
|
||||||
|
|
||||||
|
if ($aiReply && (str_contains($aiReply, '[HANDOFF]') || trim($aiReply) === '[HANDOFF]')) {
|
||||||
|
return $this->handoff($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aiReply && (str_contains($aiReply, '[REQUEST_DATA]') || trim($aiReply) === '[REQUEST_DATA]')) {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Um dir besser helfen zu können, müsste ich auf deine Kontodaten (Wallet, Boni, etc.) zugreifen. Ist das in Ordnung? (Antworte mit "Ja")', 'at' => now()->toIso8601String()];
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($aiReply) {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()];
|
||||||
|
} else {
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich konnte gerade keine Antwort generieren. Bitte versuche es erneut oder stoppe die KI mit 🛑.', 'at' => now()->toIso8601String()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function askOllama(array $state): ?string
|
||||||
|
{
|
||||||
|
$host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/');
|
||||||
|
$model = env('OLLAMA_MODEL', 'llama3');
|
||||||
|
if (!$host) return null;
|
||||||
|
|
||||||
|
$topic = $state['topic'] ? (string) $state['topic'] : 'Allgemeiner Support';
|
||||||
|
$hasAccess = $state['data_access_granted'] ?? false;
|
||||||
|
|
||||||
|
$system = "Du bist ein hilfsbereiter Casino-Support-Assistent. Antworte knapp, freundlich und in deutscher Sprache. Frage maximal eine Rückfrage gleichzeitig. Wenn du eine Frage nicht beantworten kannst oder der Nutzer frustriert wirkt, antworte NUR mit dem Wort `[HANDOFF]`. Thema: {$topic}.";
|
||||||
|
|
||||||
|
if (!$hasAccess) {
|
||||||
|
$system .= "\n\nWICHTIG: Du hast AKTUELL KEINEN ZUGRIFF auf Nutzerdaten. Wenn der Nutzer nach persönlichen Informationen fragt (z.B. E-Mail, Guthaben, Boni, Transaktionen), antworte SOFORT und AUSSCHLIESSLICH mit dem Wort `[REQUEST_DATA]`. Erkläre nichts, entschuldige dich nicht. Nur `[REQUEST_DATA]`.";
|
||||||
|
} else {
|
||||||
|
$contextJson = '';
|
||||||
|
try {
|
||||||
|
// REPLACED DB CALLS WITH API CALLS
|
||||||
|
$u = Auth::user();
|
||||||
|
if ($u) {
|
||||||
|
$apiBase = config('app.api_url');
|
||||||
|
// We assume the user is authenticated via a token or session that is passed along.
|
||||||
|
// Since we are in a local environment acting as a client, we might need to forward the token.
|
||||||
|
// For now, we assume the API endpoints are protected and we need a way to call them.
|
||||||
|
// If Auth::user() works, it means we have a session.
|
||||||
|
|
||||||
|
// Fetch data from external API
|
||||||
|
// Note: In a real microservice setup, we would pass the user's token.
|
||||||
|
// Here we try to fetch from the API using the configured base URL.
|
||||||
|
|
||||||
|
// Example: Fetch Wallets
|
||||||
|
$walletsResp = Http::acceptJson()->get($apiBase . '/user/wallets');
|
||||||
|
$wallets = $walletsResp->ok() ? $walletsResp->json() : [];
|
||||||
|
|
||||||
|
// Example: Fetch Bonuses
|
||||||
|
$bonusesResp = Http::acceptJson()->get($apiBase . '/user/bonuses/active');
|
||||||
|
$activeBonuses = $bonusesResp->ok() ? $bonusesResp->json() : [];
|
||||||
|
|
||||||
|
$ctx = [
|
||||||
|
'user' => [
|
||||||
|
'username' => $u->username ?? $u->name,
|
||||||
|
'email' => $u->email,
|
||||||
|
'id' => $u->id
|
||||||
|
],
|
||||||
|
'wallets' => $wallets,
|
||||||
|
'active_bonuses' => $activeBonuses,
|
||||||
|
];
|
||||||
|
$contextJson = json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fallback if API calls fail
|
||||||
|
\Illuminate\Support\Facades\Log::error('Failed to fetch remote context: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($contextJson) {
|
||||||
|
$system .= "\n\nNutzerdaten (ZUGRIFF ERLAUBT):\n" . $contextJson . "\n\nDU DARFST DIESE DATEN JETZT VERWENDEN. Die Daten im JSON beziehen sich AUSSCHLIESSLICH auf den aktuellen Gesprächspartner. Wenn der Nutzer nach seinen EIGENEN Daten fragt (z.B. 'meine E-Mail', 'mein Guthaben'), antworte ihm direkt mit den Werten aus dem JSON. Wenn der Nutzer nach den Daten einer ANDEREN Person fragt (z.B. 'die E-Mail von Bingo'), musst du die Anfrage ablehnen und antworten, dass du nur Auskunft über sein eigenes Konto geben darfst.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$recent = array_slice($state['messages'], -10);
|
||||||
|
$chatText = $system."\n\n";
|
||||||
|
foreach ($recent as $m) {
|
||||||
|
$role = $m['sender'] === 'user' ? 'User' : ucfirst($m['sender']);
|
||||||
|
$chatText .= "{$role}: {$m['body']}\n";
|
||||||
|
}
|
||||||
|
$chatText .= "Assistant:";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(12)->post($host.'/api/generate', [
|
||||||
|
'model' => $model,
|
||||||
|
'prompt' => $chatText,
|
||||||
|
'stream' => false,
|
||||||
|
'options' => ['temperature' => 0.3, 'num_predict' => 180],
|
||||||
|
]);
|
||||||
|
if (!$res->ok()) return null;
|
||||||
|
$json = $res->json();
|
||||||
|
$out = trim((string)($json['response'] ?? ''));
|
||||||
|
return $out ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function status(Request $request) { return response()->json($this->sessionState($request)); }
|
||||||
|
|
||||||
|
public function stop(Request $request)
|
||||||
|
{
|
||||||
|
abort_unless(Auth::check(), 401);
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') {
|
||||||
|
return response()->json(['error' => 'No active chat thread.'], 422);
|
||||||
|
}
|
||||||
|
if ($state['status'] === 'ai') {
|
||||||
|
$state['status'] = 'stopped';
|
||||||
|
$state['messages'][] = [
|
||||||
|
'id' => Str::uuid()->toString(),
|
||||||
|
'sender' => 'system',
|
||||||
|
'body' => 'KI-Assistent gestoppt. Du kannst einen Mitarbeiter anfordern.',
|
||||||
|
'at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
}
|
||||||
|
return response()->json($state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handoff(Request $request) {
|
||||||
|
$this->ensureEnabled();
|
||||||
|
abort_unless(Auth::check(), 401);
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
if (!$state['thread_id']) return response()->json(['message' => 'No active support thread'], 422);
|
||||||
|
$state['status'] = 'handoff';
|
||||||
|
$state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich leite dich an einen Mitarbeiter weiter. Bitte habe einen Moment Geduld.', 'at' => now()->toIso8601String()];
|
||||||
|
$this->saveState($request, $state);
|
||||||
|
$webhook = env('SUPPORT_DASHBOARD_WEBHOOK_URL');
|
||||||
|
if ($webhook) { try { Http::timeout(5)->post($webhook, ['event' => 'support.handoff', 'thread_id' => $state['thread_id']]); } catch (\Throwable $e) {} }
|
||||||
|
return response()->json(['message' => 'Hand-off requested', 'state' => $state]);
|
||||||
|
}
|
||||||
|
public function stream(Request $request) {
|
||||||
|
$state = $this->sessionState($request);
|
||||||
|
if (empty($state['thread_id'])) return response('', 204);
|
||||||
|
$threadId = $state['thread_id'];
|
||||||
|
return new StreamedResponse(function () use ($request, $threadId) {
|
||||||
|
$send = fn($data) => print('data: ' . json_encode($data) . "\n\n");
|
||||||
|
$start = time();
|
||||||
|
$lastUpdated = null;
|
||||||
|
$lastCount = -1;
|
||||||
|
while (time() - $start < 60) {
|
||||||
|
usleep(500000);
|
||||||
|
$cached = cache()->get('support_threads:'.$threadId);
|
||||||
|
$nowState = is_array($cached) ? $cached : $this->sessionState($request);
|
||||||
|
$count = count($nowState['messages'] ?? []);
|
||||||
|
$updated = $nowState['updated_at'] ?? null;
|
||||||
|
if ($count !== $lastCount || $updated !== $lastUpdated) {
|
||||||
|
$send($nowState);
|
||||||
|
$lastCount = $count;
|
||||||
|
$lastUpdated = $updated;
|
||||||
|
}
|
||||||
|
if ((time() - $start) % 15 === 0) { print(": ping\n\n"); }
|
||||||
|
@ob_flush(); @flush();
|
||||||
|
}
|
||||||
|
}, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', 'X-Accel-Buffering' => 'no']);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
app/Http/Controllers/TrophyController.php
Normal file
146
app/Http/Controllers/TrophyController.php
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\GameBet;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserAchievement;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class TrophyController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* All available achievements with their unlock conditions (for display).
|
||||||
|
*/
|
||||||
|
public static array $definitions = [
|
||||||
|
'first_bet' => ['title' => 'First Bet', 'desc' => 'Place your very first bet.', 'icon' => '🎲'],
|
||||||
|
'first_win' => ['title' => 'First Win', 'desc' => 'Win your first game.', 'icon' => '🏆'],
|
||||||
|
'big_winner' => ['title' => 'Big Winner', 'desc' => 'Land a 10× or higher multiplier.', 'icon' => '💥'],
|
||||||
|
'high_roller' => ['title' => 'High Roller', 'desc' => 'Wager 100+ BTX in total.', 'icon' => '💎'],
|
||||||
|
'frequent_player' => ['title' => 'Frequent Player', 'desc' => 'Place 50+ bets.', 'icon' => '🔥'],
|
||||||
|
'hundred_bets' => ['title' => 'Centurion', 'desc' => 'Place 100+ bets.', 'icon' => '⚡'],
|
||||||
|
'vault_user' => ['title' => 'Vault Guardian', 'desc' => 'Make your first vault deposit.', 'icon' => '🔒'],
|
||||||
|
'vip_level2' => ['title' => 'Rising Star', 'desc' => 'Reach VIP Level 2.', 'icon' => '⭐'],
|
||||||
|
'vip_level5' => ['title' => 'Elite', 'desc' => 'Reach VIP Level 5.', 'icon' => '👑'],
|
||||||
|
'guild_member' => ['title' => 'Team Player', 'desc' => 'Join a guild.', 'icon' => '🛡️'],
|
||||||
|
'promo_user' => ['title' => 'Promo Hunter', 'desc' => 'Redeem your first promo code.', 'icon' => '🎁'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trophy room page — show user's achievements + locked ones.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
// Sync achievements before showing
|
||||||
|
$this->syncAchievements($user);
|
||||||
|
|
||||||
|
$unlocked = UserAchievement::where('user_id', $user->id)
|
||||||
|
->pluck('unlocked_at', 'achievement_key');
|
||||||
|
|
||||||
|
$achievements = [];
|
||||||
|
foreach (self::$definitions as $key => $def) {
|
||||||
|
$achievements[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'title' => $def['title'],
|
||||||
|
'description' => $def['desc'],
|
||||||
|
'icon' => $def['icon'],
|
||||||
|
'unlocked' => isset($unlocked[$key]),
|
||||||
|
'unlocked_at' => isset($unlocked[$key]) ? $unlocked[$key]->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: unlocked first, then locked
|
||||||
|
usort($achievements, fn($a, $b) => ($b['unlocked'] <=> $a['unlocked']));
|
||||||
|
|
||||||
|
return Inertia::render('Trophy', [
|
||||||
|
'achievements' => $achievements,
|
||||||
|
'total' => count(self::$definitions),
|
||||||
|
'unlocked' => $unlocked->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trophy room for a specific user (public profiles only).
|
||||||
|
*/
|
||||||
|
public function show(Request $request, string $username)
|
||||||
|
{
|
||||||
|
$user = User::where('username', $username)->firstOrFail();
|
||||||
|
|
||||||
|
// Only show trophies for public profiles (or own profile)
|
||||||
|
if (!$user->is_public && Auth::id() !== $user->id) {
|
||||||
|
abort(403, 'This profile is private.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->syncAchievements($user);
|
||||||
|
|
||||||
|
$unlocked = UserAchievement::where('user_id', $user->id)
|
||||||
|
->pluck('unlocked_at', 'achievement_key');
|
||||||
|
|
||||||
|
$achievements = [];
|
||||||
|
foreach (self::$definitions as $key => $def) {
|
||||||
|
$achievements[] = [
|
||||||
|
'key' => $key,
|
||||||
|
'title' => $def['title'],
|
||||||
|
'description' => $def['desc'],
|
||||||
|
'icon' => $def['icon'],
|
||||||
|
'unlocked' => isset($unlocked[$key]),
|
||||||
|
'unlocked_at' => isset($unlocked[$key]) ? $unlocked[$key]->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($achievements, fn($a, $b) => ($b['unlocked'] <=> $a['unlocked']));
|
||||||
|
|
||||||
|
return Inertia::render('Trophy', [
|
||||||
|
'achievements' => $achievements,
|
||||||
|
'total' => count(self::$definitions),
|
||||||
|
'unlocked' => $unlocked->count(),
|
||||||
|
'profileUser' => ['username' => $user->username, 'avatar' => $user->avatar],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check and unlock achievements for the given user.
|
||||||
|
*/
|
||||||
|
public function syncAchievements(\App\Models\User $user): void
|
||||||
|
{
|
||||||
|
$bets = GameBet::where('user_id', $user->id);
|
||||||
|
$betCount = $bets->count();
|
||||||
|
$totalWager = $bets->sum('wager_amount');
|
||||||
|
$maxMulti = $bets->max('payout_multiplier') ?? 0;
|
||||||
|
$hasWin = $bets->where('payout_amount', '>', 0)->exists();
|
||||||
|
|
||||||
|
$toUnlock = [];
|
||||||
|
|
||||||
|
if ($betCount >= 1) $toUnlock[] = 'first_bet';
|
||||||
|
if ($hasWin) $toUnlock[] = 'first_win';
|
||||||
|
if ($maxMulti >= 10) $toUnlock[] = 'big_winner';
|
||||||
|
if ($totalWager >= 100) $toUnlock[] = 'high_roller';
|
||||||
|
if ($betCount >= 50) $toUnlock[] = 'frequent_player';
|
||||||
|
if ($betCount >= 100) $toUnlock[] = 'hundred_bets';
|
||||||
|
if ($user->vip_level >= 2) $toUnlock[] = 'vip_level2';
|
||||||
|
if ($user->vip_level >= 5) $toUnlock[] = 'vip_level5';
|
||||||
|
if ($user->guildMember()->exists()) $toUnlock[] = 'guild_member';
|
||||||
|
|
||||||
|
// Vault usage
|
||||||
|
if (\App\Models\WalletTransfer::where('user_id', $user->id)->where('type', 'deposit')->exists()) {
|
||||||
|
$toUnlock[] = 'vault_user';
|
||||||
|
}
|
||||||
|
// Promo usage
|
||||||
|
if (\App\Models\PromoUsage::where('user_id', $user->id)->exists()) {
|
||||||
|
$toUnlock[] = 'promo_user';
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toUnlock as $key) {
|
||||||
|
UserAchievement::firstOrCreate(
|
||||||
|
['user_id' => $user->id, 'achievement_key' => $key],
|
||||||
|
['unlocked_at' => now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Controllers/UserBonusController.php
Normal file
54
app/Http/Controllers/UserBonusController.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserBonusController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return authenticated user's bonuses (active first), lightweight JSON.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/users/me/bonuses', [], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$body = $res->json() ?: [];
|
||||||
|
$items = $body['data'] ?? $body['bonuses'] ?? $body;
|
||||||
|
$out = [];
|
||||||
|
foreach ((array) $items as $b) {
|
||||||
|
if (!is_array($b)) continue;
|
||||||
|
$out[] = [
|
||||||
|
'id' => $b['id'] ?? null,
|
||||||
|
'amount' => isset($b['amount']) ? (float) $b['amount'] : 0.0,
|
||||||
|
'wager_required' => isset($b['wager_required']) ? (float) $b['wager_required'] : 0.0,
|
||||||
|
'wager_progress' => isset($b['wager_progress']) ? (float) $b['wager_progress'] : 0.0,
|
||||||
|
'expires_at' => $b['expires_at'] ?? null,
|
||||||
|
'is_active' => (bool) ($b['is_active'] ?? false),
|
||||||
|
'completed_at' => $b['completed_at'] ?? null,
|
||||||
|
'promo' => isset($b['promo']) && is_array($b['promo']) ? [
|
||||||
|
'code' => $b['promo']['code'] ?? null,
|
||||||
|
'wager_multiplier' => isset($b['promo']['wager_multiplier']) ? (int) $b['promo']['wager_multiplier'] : null,
|
||||||
|
] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return response()->json(['data' => $out], 200);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
171
app/Http/Controllers/UserRestrictionApiController.php
Normal file
171
app/Http/Controllers/UserRestrictionApiController.php
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UserRestrictionApiController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
private array $allowedTypes = [
|
||||||
|
'account_ban',
|
||||||
|
'chat_ban',
|
||||||
|
'deposit_block',
|
||||||
|
'withdrawal_block',
|
||||||
|
'support_block',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
// Inline Bearer token middleware like BonusApiController
|
||||||
|
$this->middleware(function ($request, $next) {
|
||||||
|
$provided = $this->extractToken($request);
|
||||||
|
$expected = config('services.moderation_api.token');
|
||||||
|
|
||||||
|
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractToken(Request $request): ?string
|
||||||
|
{
|
||||||
|
$auth = $request->header('Authorization');
|
||||||
|
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||||
|
return substr($auth, 7);
|
||||||
|
}
|
||||||
|
return $request->query('api_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users/{id}/restrictions?active_only=1
|
||||||
|
public function listForUser(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$query = [];
|
||||||
|
if ($request->boolean('active_only')) {
|
||||||
|
$query['active_only'] = 1;
|
||||||
|
}
|
||||||
|
$res = $this->client->get($request, "/users/{$userId}/restrictions", $query, retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/users/{id}/restrictions/check
|
||||||
|
public function checkForUser(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, "/users/{$userId}/restrictions/check", [], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/users/{id}/restrictions
|
||||||
|
public function createForUser(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
$data = $this->validatePayload($request, partial: false);
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, "/users/{$userId}/restrictions", $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: [], 201);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/users/{id}/restrictions/upsert
|
||||||
|
public function upsertForUser(Request $request, int $userId)
|
||||||
|
{
|
||||||
|
$data = $this->validatePayload($request, partial: true);
|
||||||
|
if (empty($data['type'])) {
|
||||||
|
return response()->json(['message' => 'type is required for upsert'], 422);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, "/users/{$userId}/restrictions/upsert", $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
// 200 or 201 upstream; forward as given
|
||||||
|
$status = $res->status() === 201 ? 201 : 200;
|
||||||
|
return response()->json($res->json() ?: [], $status);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /api/restrictions/{id}
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$data = $this->validatePayload($request, partial: true);
|
||||||
|
try {
|
||||||
|
$res = $this->client->patch($request, "/restrictions/{$id}", $data);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: []);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE /api/restrictions/{id}
|
||||||
|
public function destroy(Request $request, int $id)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$res = $this->client->delete($request, "/restrictions/{$id}");
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: ['message' => 'Deactivated']);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validatePayload(Request $request, bool $partial = false): array
|
||||||
|
{
|
||||||
|
$required = $partial ? 'sometimes' : 'required';
|
||||||
|
$rules = [
|
||||||
|
'type' => [$required, Rule::in($this->allowedTypes)],
|
||||||
|
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'notes' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'imposed_by' => ['sometimes', 'nullable', 'integer'],
|
||||||
|
'starts_at' => ['sometimes', 'nullable', 'date'],
|
||||||
|
'ends_at' => ['sometimes', 'nullable', 'date', 'after_or_equal:starts_at'],
|
||||||
|
'active' => ['sometimes', 'boolean'],
|
||||||
|
'source' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
|
'metadata' => ['sometimes', 'nullable', 'array'],
|
||||||
|
];
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
return $validated;
|
||||||
|
}
|
||||||
|
}
|
||||||
124
app/Http/Controllers/VaultApiController.php
Normal file
124
app/Http/Controllers/VaultApiController.php
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class VaultApiController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
// Inline token check middleware (no alias registration needed)
|
||||||
|
$this->middleware(function ($request, $next) {
|
||||||
|
$provided = $this->extractToken($request);
|
||||||
|
$expected = config('services.vault_api.token');
|
||||||
|
|
||||||
|
if (!$expected || !hash_equals((string) $expected, (string) $provided)) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractToken(Request $request): ?string
|
||||||
|
{
|
||||||
|
$auth = $request->header('Authorization');
|
||||||
|
if ($auth && str_starts_with($auth, 'Bearer ')) {
|
||||||
|
return substr($auth, 7);
|
||||||
|
}
|
||||||
|
return $request->query('api_token'); // fallback: allow ?api_token=
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/users/{id}/vault
|
||||||
|
*/
|
||||||
|
public function showForUser(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$perPage = min(200, max(1, (int) $request->query('per_page', 50)));
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, "/users/{$id}/vault", [ 'per_page' => $perPage ], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: [], 200);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/{id}/vault/deposit
|
||||||
|
*/
|
||||||
|
public function depositForUser(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||||
|
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||||
|
'reason' => ['sometimes','nullable','string','max:255'],
|
||||||
|
'created_by' => ['sometimes','nullable','integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Pass through metadata we can gather; upstream decides usage
|
||||||
|
$payload = [
|
||||||
|
'amount' => $data['amount'],
|
||||||
|
'idempotency_key' => $data['idempotency_key'] ?? null,
|
||||||
|
'reason' => $data['reason'] ?? null,
|
||||||
|
'created_by' => $data['created_by'] ?? null,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, "/users/{$id}/vault/deposit", $payload);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: [], 201);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/users/{id}/vault/withdraw
|
||||||
|
*/
|
||||||
|
public function withdrawForUser(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||||
|
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||||
|
'reason' => ['sometimes','nullable','string','max:255'],
|
||||||
|
'created_by' => ['sometimes','nullable','integer'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'amount' => $data['amount'],
|
||||||
|
'idempotency_key' => $data['idempotency_key'] ?? null,
|
||||||
|
'reason' => $data['reason'] ?? null,
|
||||||
|
'created_by' => $data['created_by'] ?? null,
|
||||||
|
'ip' => $request->ip(),
|
||||||
|
'user_agent' => $request->userAgent(),
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, "/users/{$id}/vault/withdraw", $payload);
|
||||||
|
if ($res->successful()) {
|
||||||
|
return response()->json($res->json() ?: [], 201);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) return $this->mapClientError($res);
|
||||||
|
if ($res->serverError()) return $this->mapServiceUnavailable($res);
|
||||||
|
return $this->mapBadGateway();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return $this->mapBadGateway('API server not reachable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Http/Controllers/VaultController.php
Normal file
121
app/Http/Controllers/VaultController.php
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\WalletTransfer;
|
||||||
|
use App\Services\WalletService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class VaultController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly WalletService $wallet)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private const SUPPORTED_CURRENCIES = ['BTX', 'BTC', 'ETH', 'SOL'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wallet/vault — returns balances for all currencies
|
||||||
|
*/
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$perPage = min(100, max(1, (int) $request->query('per_page', 20)));
|
||||||
|
$items = WalletTransfer::where('user_id', $user->id)
|
||||||
|
->orderByDesc('id')
|
||||||
|
->limit($perPage)
|
||||||
|
->get(['id','type','amount','currency','created_at']);
|
||||||
|
|
||||||
|
$transfers = $items->map(fn($t) => [
|
||||||
|
'id' => $t->id,
|
||||||
|
'type' => $t->type,
|
||||||
|
'amount' => (string) $t->amount,
|
||||||
|
'currency' => $t->currency,
|
||||||
|
'created_at' => $t->created_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$map = $user->vault_balances ?? [];
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'balance' => (string) ($user->balance ?? '0.0000'),
|
||||||
|
'vault_balance' => (string) ($user->vault_balance ?? '0.0000'),
|
||||||
|
'vault_balances' => array_merge(
|
||||||
|
['BTX' => (string) ($user->vault_balance ?? '0.0000')],
|
||||||
|
$map
|
||||||
|
),
|
||||||
|
'currency' => 'BTX',
|
||||||
|
'transfers' => $transfers,
|
||||||
|
'now' => now()->toIso8601String(),
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wallet/vault/deposit
|
||||||
|
*/
|
||||||
|
public function deposit(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||||
|
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||||
|
'currency' => ['sometimes','string','in:' . implode(',', self::SUPPORTED_CURRENCIES)],
|
||||||
|
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currency = strtoupper($data['currency'] ?? 'BTX');
|
||||||
|
|
||||||
|
if ($resp = $this->wallet->verifyVaultPin($user, (string) $data['pin'])) {
|
||||||
|
return $resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = $this->wallet->depositToVault($user, $data['amount'], $data['idempotency_key'] ?? null, $currency);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => ['type' => 'deposit', 'amount' => $data['amount'], 'currency' => $currency],
|
||||||
|
'balances' => [
|
||||||
|
'balance' => $out['balance'],
|
||||||
|
'vault_balance' => $out['vault_balance'],
|
||||||
|
'vault_balances' => $out['vault_balances'],
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wallet/vault/withdraw
|
||||||
|
*/
|
||||||
|
public function withdraw(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'amount' => ['required','string','regex:/^\d+(?:\.\d{1,4})?$/'],
|
||||||
|
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||||
|
'currency' => ['sometimes','string','in:' . implode(',', self::SUPPORTED_CURRENCIES)],
|
||||||
|
'idempotency_key' => ['sometimes','nullable','string','max:64'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currency = strtoupper($data['currency'] ?? 'BTX');
|
||||||
|
|
||||||
|
if ($resp = $this->wallet->verifyVaultPin($user, (string) $data['pin'])) {
|
||||||
|
return $resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = $this->wallet->withdrawFromVault($user, $data['amount'], $data['idempotency_key'] ?? null, $currency);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => ['type' => 'withdraw', 'amount' => $data['amount'], 'currency' => $currency],
|
||||||
|
'balances' => [
|
||||||
|
'balance' => $out['balance'],
|
||||||
|
'vault_balance' => $out['vault_balance'],
|
||||||
|
'vault_balances' => $out['vault_balances'],
|
||||||
|
],
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Http/Controllers/VaultPinController.php
Normal file
86
app/Http/Controllers/VaultPinController.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class VaultPinController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* POST /api/wallet/vault/pin/set — local implementation
|
||||||
|
*/
|
||||||
|
public function set(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||||
|
'current_pin' => ['sometimes','nullable','string','regex:/^\d{4,8}$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If PIN already set, require current_pin and verify
|
||||||
|
if (!empty($user->vault_pin_hash)) {
|
||||||
|
if (empty($data['current_pin']) || !Hash::check((string) $data['current_pin'], $user->vault_pin_hash)) {
|
||||||
|
return response()->json(['message' => 'Current PIN invalid'], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->vault_pin_hash = Hash::make((string) $data['pin']);
|
||||||
|
$user->vault_pin_set_at = now();
|
||||||
|
$user->vault_pin_attempts = 0;
|
||||||
|
$user->vault_pin_locked_until = null;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Vault PIN saved.',
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/wallet/vault/pin/verify — local implementation
|
||||||
|
*/
|
||||||
|
public function verify(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'pin' => ['required','string','regex:/^\d{4,8}$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Locked?
|
||||||
|
if (!empty($user->vault_pin_locked_until) && now()->lessThan($user->vault_pin_locked_until)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Vault PIN locked. Try again later.',
|
||||||
|
'locked_until' => optional($user->vault_pin_locked_until)->toIso8601String(),
|
||||||
|
], 423);
|
||||||
|
}
|
||||||
|
if (empty($user->vault_pin_hash)) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Vault PIN not set'], 400);
|
||||||
|
}
|
||||||
|
if (!Hash::check((string) $data['pin'], $user->vault_pin_hash)) {
|
||||||
|
$attempts = (int) ($user->vault_pin_attempts ?? 0) + 1;
|
||||||
|
$user->vault_pin_attempts = $attempts;
|
||||||
|
if ($attempts >= 5) {
|
||||||
|
$user->vault_pin_locked_until = now()->addMinutes(15);
|
||||||
|
$user->vault_pin_attempts = 0;
|
||||||
|
}
|
||||||
|
$user->save();
|
||||||
|
return response()->json(['success' => false, 'message' => 'Invalid PIN'], 423);
|
||||||
|
}
|
||||||
|
// OK
|
||||||
|
if (!empty($user->vault_pin_attempts)) {
|
||||||
|
$user->vault_pin_attempts = 0;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Verified',
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
85
app/Http/Controllers/VipController.php
Normal file
85
app/Http/Controllers/VipController.php
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Concerns\ProxiesBackend;
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class VipController extends Controller
|
||||||
|
{
|
||||||
|
use ProxiesBackend;
|
||||||
|
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIP Levels page → proxy to external API.
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$defaultProps = [
|
||||||
|
'claimedLevels' => [],
|
||||||
|
'cashRewards' => [],
|
||||||
|
'userStats' => $user ? [
|
||||||
|
'vip_level' => $user->vip_level ?? 0,
|
||||||
|
'vip_points' => $user->stats?->vip_points ?? 0,
|
||||||
|
] : null,
|
||||||
|
'userVipLevel' => $user?->vip_level ?? 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$res = $this->client->get($request, '/vip-levels', [], retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$j = $res->json() ?: [];
|
||||||
|
return Inertia::render('VipLevels', [
|
||||||
|
'claimedLevels' => $j['claimedLevels'] ?? $j['claimed_levels'] ?? [],
|
||||||
|
'cashRewards' => $j['cashRewards'] ?? $j['rewards'] ?? [],
|
||||||
|
'userStats' => $j['userStats'] ?? $j['stats'] ?? $defaultProps['userStats'],
|
||||||
|
'userVipLevel' => $j['userVipLevel'] ?? $j['vip_level'] ?? $defaultProps['userVipLevel'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Fall through to local fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render page with local data when external API is unavailable
|
||||||
|
return Inertia::render('VipLevels', $defaultProps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Claim VIP reward → proxy to external API.
|
||||||
|
*/
|
||||||
|
public function claim(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'level' => 'required|integer|min:1|max:100',
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$res = $this->client->post($request, '/vip-levels/claim', [
|
||||||
|
'level' => (int) $data['level'],
|
||||||
|
]);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$body = $res->json() ?: [];
|
||||||
|
// Backward compat: ensure a success message
|
||||||
|
if (!isset($body['message'])) {
|
||||||
|
$body['message'] = 'Reward claimed successfully!';
|
||||||
|
}
|
||||||
|
return back()->with('success', $body['message']);
|
||||||
|
}
|
||||||
|
if ($res->clientError()) {
|
||||||
|
$msg = data_get($res->json(), 'message', 'Invalid request');
|
||||||
|
return back()->withErrors(['message' => $msg]);
|
||||||
|
}
|
||||||
|
if ($res->serverError()) {
|
||||||
|
return back()->withErrors(['message' => 'Service temporarily unavailable']);
|
||||||
|
}
|
||||||
|
return back()->withErrors(['message' => 'API server not reachable']);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return back()->withErrors(['message' => 'API server not reachable']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
app/Http/Controllers/WalletController.php
Normal file
89
app/Http/Controllers/WalletController.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use App\Models\GameBet;
|
||||||
|
|
||||||
|
class WalletController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Wallet overview page — now fully local (no external API).
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
// Synchronize balance if possible from external API during page load for better initial state
|
||||||
|
// (Optional, maybe already done in Proxy if user just came from a game)
|
||||||
|
|
||||||
|
// Frontend expects:
|
||||||
|
// - wallets: array of currency accounts (we return an empty list for now; extend later if needed)
|
||||||
|
// - btxBalance: main BTX balance as number/string
|
||||||
|
$wallets = [];
|
||||||
|
$btxBalance = (string) ($user->balance ?? '0');
|
||||||
|
|
||||||
|
return Inertia::render('Wallet', [
|
||||||
|
'wallets' => $wallets,
|
||||||
|
'btxBalance' => $btxBalance,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/wallet/balance — return current authenticated user balance
|
||||||
|
*/
|
||||||
|
public function balance(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
if (!$user) return response()->json(['error' => 'unauthorized'], 401);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'balance' => (string) $user->balance,
|
||||||
|
'btx_balance' => (string) $user->balance,
|
||||||
|
// Add other currencies if user has multiple wallets
|
||||||
|
'wallets' => $user->wallets()->get()->map(fn($w) => [
|
||||||
|
'currency' => $w->currency,
|
||||||
|
'balance' => (string) $w->balance,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /wallet/bets — fetch user's game bets
|
||||||
|
*/
|
||||||
|
public function bets(Request $request)
|
||||||
|
{
|
||||||
|
$user = Auth::user();
|
||||||
|
abort_unless($user, 403);
|
||||||
|
|
||||||
|
$query = GameBet::where('user_id', $user->id);
|
||||||
|
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$query->where('game_name', 'like', '%' . $request->input('search') . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = $request->input('sort', 'created_at');
|
||||||
|
$order = $request->input('order', 'desc');
|
||||||
|
|
||||||
|
if (in_array($sort, ['created_at', 'wager_amount', 'payout_amount', 'payout_multiplier', 'game_name'])) {
|
||||||
|
$query->orderBy($sort, $order === 'asc' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bets = $query->paginate(20)->through(function ($bet) {
|
||||||
|
return [
|
||||||
|
'id' => $bet->id,
|
||||||
|
'game_name' => $bet->game_name,
|
||||||
|
'wager_amount' => (string) $bet->wager_amount,
|
||||||
|
'payout_multiplier' => (string) $bet->payout_multiplier,
|
||||||
|
'payout_amount' => (string) $bet->payout_amount,
|
||||||
|
'currency' => $bet->currency,
|
||||||
|
'created_at' => $bet->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($bets);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Middleware/CheckBanned.php
Normal file
25
app/Http/Middleware/CheckBanned.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class CheckBanned
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
if (Auth::check() && Auth::user()->is_banned) {
|
||||||
|
// Get reason
|
||||||
|
$ban = Auth::user()->restrictions()->where('type', 'account_ban')->where('active', true)->first();
|
||||||
|
|
||||||
|
return Inertia::render('Errors/Banned', [
|
||||||
|
'reason' => $ban ? $ban->reason : 'No reason provided.'
|
||||||
|
])->toResponse($request)->setStatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Http/Middleware/DetectCiphertextInJson.php
Normal file
43
app/Http/Middleware/DetectCiphertextInJson.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class DetectCiphertextInJson
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Only act in non-production environments
|
||||||
|
if (app()->environment(['local', 'development', 'staging'])) {
|
||||||
|
$contentType = (string) $response->headers->get('Content-Type', '');
|
||||||
|
if (str_contains($contentType, 'application/json')) {
|
||||||
|
$body = (string) $response->getContent();
|
||||||
|
if ($this->containsLaravelCiphertext($body)) {
|
||||||
|
// Log minimal info without PII values
|
||||||
|
logger()->warning('Ciphertext detected in JSON response', [
|
||||||
|
'path' => $request->path(),
|
||||||
|
'method' => $request->getMethod(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If you prefer to hard-block in dev/stage, uncomment:
|
||||||
|
// return response()->json(['error' => 'Ciphertext detected in response'], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function containsLaravelCiphertext(string $json): bool
|
||||||
|
{
|
||||||
|
// Heuristic: look for base64-encoded JSON blobs and typical Laravel keys iv/value/mac
|
||||||
|
if (!str_contains($json, 'eyJ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (str_contains($json, '"iv"') && str_contains($json, '"value"') && str_contains($json, '"mac"'));
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Middleware/EnforceRestriction.php
Normal file
67
app/Http/Middleware/EnforceRestriction.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\BackendHttpClient;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class EnforceRestriction
|
||||||
|
{
|
||||||
|
public function __construct(private readonly BackendHttpClient $client)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
* @param array<int,string> $types One or more restriction types to check
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, ...$types)
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($types)) {
|
||||||
|
$types = ['account_ban'];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Ask upstream whether any of the requested types are active for the current user
|
||||||
|
$query = ['types' => implode(',', $types)];
|
||||||
|
$res = $this->client->get($request, '/users/me/restrictions/check', $query, retry: true);
|
||||||
|
if ($res->successful()) {
|
||||||
|
$json = $res->json() ?: [];
|
||||||
|
$data = $json['data'] ?? $json; // support either {data:{...}} or flat map
|
||||||
|
$isRestricted = false;
|
||||||
|
$hitType = null;
|
||||||
|
foreach ($types as $t) {
|
||||||
|
if (!empty($data[$t])) { $isRestricted = true; $hitType = $t; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isRestricted) {
|
||||||
|
// For web requests: log out if account is banned and block access
|
||||||
|
if (!$request->expectsJson() && in_array('account_ban', $types, true)) {
|
||||||
|
Auth::logout();
|
||||||
|
$request->session()->invalidate();
|
||||||
|
$request->session()->regenerateToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'message' => 'Access is restricted for this action.',
|
||||||
|
'type' => $hitType,
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json($payload, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On client/server error we fail open to avoid locking out users due to upstream outage
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// swallow and continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/Http/Middleware/GeoBlockMiddleware.php
Normal file
168
app/Http/Middleware/GeoBlockMiddleware.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class GeoBlockMiddleware
|
||||||
|
{
|
||||||
|
private const BYPASS_PATHS = [
|
||||||
|
'blocked', 'favicon.ico', 'up',
|
||||||
|
'admin', 'login', 'register',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
// Skip for admin users, static paths, and the blocked page itself
|
||||||
|
if ($this->shouldBypass($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = AppSetting::get('geo.settings', []);
|
||||||
|
|
||||||
|
if (empty($settings['enabled'])) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip = $request->ip();
|
||||||
|
|
||||||
|
// Never block localhost in dev
|
||||||
|
if (in_array($ip, ['127.0.0.1', '::1', '::ffff:127.0.0.1'])) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$geoData = $this->fetchGeoData($ip);
|
||||||
|
$country = $geoData['countryCode'] ?? null;
|
||||||
|
$isProxy = $geoData['proxy'] ?? false;
|
||||||
|
$isHosting = $geoData['hosting'] ?? false;
|
||||||
|
|
||||||
|
// VPN / Proxy check
|
||||||
|
if (!empty($settings['vpn_block'])) {
|
||||||
|
$vpnProvider = $settings['vpn_provider'] ?? 'none';
|
||||||
|
|
||||||
|
$isVpn = match ($vpnProvider) {
|
||||||
|
'ipqualityscore' => Cache::remember("geo_vpn_iqs_{$ip}", 3600, fn() => $this->checkIpQualityScore($ip, $settings['vpn_api_key'] ?? '')),
|
||||||
|
'proxycheck' => Cache::remember("geo_vpn_pc_{$ip}", 3600, fn() => $this->checkProxyCheck($ip, $settings['vpn_api_key'] ?? '')),
|
||||||
|
default => ($isProxy || $isHosting), // free ip-api.com fallback
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($isVpn) {
|
||||||
|
return $this->blockResponse($request, $settings, 'vpn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country check
|
||||||
|
if ($country) {
|
||||||
|
$mode = $settings['mode'] ?? 'blacklist';
|
||||||
|
$blocked = array_map('strtoupper', $settings['blocked_countries'] ?? []);
|
||||||
|
$allowed = array_map('strtoupper', $settings['allowed_countries'] ?? []);
|
||||||
|
$upper = strtoupper($country);
|
||||||
|
|
||||||
|
$isBlocked = match ($mode) {
|
||||||
|
'whitelist' => !empty($allowed) && !in_array($upper, $allowed),
|
||||||
|
default => !empty($blocked) && in_array($upper, $blocked),
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($isBlocked) {
|
||||||
|
return $this->blockResponse($request, $settings, 'country');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldBypass(Request $request): bool
|
||||||
|
{
|
||||||
|
$path = ltrim($request->path(), '/');
|
||||||
|
|
||||||
|
foreach (self::BYPASS_PATHS as $bypass) {
|
||||||
|
if ($path === $bypass || str_starts_with($path, $bypass . '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip API routes
|
||||||
|
if ($request->is('api/*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockResponse(Request $request, array $settings, string $reason)
|
||||||
|
{
|
||||||
|
$message = $settings['block_message'] ?? 'This service is not available in your region.';
|
||||||
|
$redirectUrl = $settings['redirect_url'] ?? '';
|
||||||
|
|
||||||
|
if ($redirectUrl) {
|
||||||
|
return redirect()->away($redirectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return Inertia::render('GeoBlocked', [
|
||||||
|
'message' => $message,
|
||||||
|
'reason' => $reason,
|
||||||
|
])->toResponse($request)->setStatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('GeoBlocked', [
|
||||||
|
'message' => $message,
|
||||||
|
'reason' => $reason,
|
||||||
|
])->toResponse($request)->setStatusCode(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchGeoData(string $ip): array
|
||||||
|
{
|
||||||
|
return Cache::remember("geo_data_{$ip}", 3600, function () use ($ip) {
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(3)->get("http://ip-api.com/json/{$ip}", [
|
||||||
|
'fields' => 'countryCode,proxy,hosting',
|
||||||
|
]);
|
||||||
|
if ($res->ok()) {
|
||||||
|
return $res->json() ?? [];
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkIpQualityScore(string $ip, string $apiKey): bool
|
||||||
|
{
|
||||||
|
if (!$apiKey) return false;
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(4)->get("https://ipqualityscore.com/api/json/ip/{$apiKey}/{$ip}", [
|
||||||
|
'strictness' => 1,
|
||||||
|
'allow_public_access_points' => 'true',
|
||||||
|
'fast' => 'true',
|
||||||
|
]);
|
||||||
|
if ($res->ok()) {
|
||||||
|
$data = $res->json();
|
||||||
|
return (bool) ($data['vpn'] ?? $data['proxy'] ?? $data['tor'] ?? false);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkProxyCheck(string $ip, string $apiKey): bool
|
||||||
|
{
|
||||||
|
if (!$apiKey) return false;
|
||||||
|
try {
|
||||||
|
$res = Http::timeout(4)->get("https://proxycheck.io/v2/{$ip}", [
|
||||||
|
'key' => $apiKey,
|
||||||
|
'vpn' => 1,
|
||||||
|
'asn' => 0,
|
||||||
|
]);
|
||||||
|
if ($res->ok()) {
|
||||||
|
$data = $res->json();
|
||||||
|
$entry = $data[$ip] ?? [];
|
||||||
|
return strtolower($entry['proxy'] ?? 'no') === 'yes';
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Middleware/HandleAppearance.php
Normal file
23
app/Http/Middleware/HandleAppearance.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class HandleAppearance
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/Http/Middleware/HandleInertiaRequests.php
Normal file
84
app/Http/Middleware/HandleInertiaRequests.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Middleware;
|
||||||
|
|
||||||
|
class HandleInertiaRequests extends Middleware
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The root template that's loaded on the first page visit.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/server-side-setup#root-template
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $rootView = 'app';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the current asset version.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/asset-versioning
|
||||||
|
*/
|
||||||
|
public function version(Request $request): ?string
|
||||||
|
{
|
||||||
|
return parent::version($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the props that are shared by default.
|
||||||
|
*
|
||||||
|
* @see https://inertiajs.com/shared-data
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function share(Request $request): array
|
||||||
|
{
|
||||||
|
$u = $request->user();
|
||||||
|
|
||||||
|
// Fully externalized mode: do not load local Eloquent relations here.
|
||||||
|
// All feature data (stats, wallets, restrictions, etc.) must be fetched via
|
||||||
|
// the external API through proxy controllers/endpoints.
|
||||||
|
|
||||||
|
return [
|
||||||
|
...parent::share($request),
|
||||||
|
'name' => config('app.name'),
|
||||||
|
'api_url' => config('app.api_url'), // Pass API URL to frontend
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'availableLocales' => [
|
||||||
|
['code' => 'en', 'label' => 'English', 'flag' => 'https://flagcdn.com/w20/gb.png'],
|
||||||
|
['code' => 'de', 'label' => 'Deutsch', 'flag' => 'https://flagcdn.com/w20/de.png'],
|
||||||
|
['code' => 'es', 'label' => 'Español', 'flag' => 'https://flagcdn.com/w20/es.png'],
|
||||||
|
['code' => 'pt_BR', 'label' => 'Português (Brasil)', 'flag' => 'https://flagcdn.com/w20/br.png'],
|
||||||
|
['code' => 'tr', 'label' => 'Türkçe', 'flag' => 'https://flagcdn.com/w20/tr.png'],
|
||||||
|
['code' => 'pl', 'label' => 'Polski', 'flag' => 'https://flagcdn.com/w20/pl.png'],
|
||||||
|
],
|
||||||
|
'dir' => 'ltr',
|
||||||
|
'auth' => [
|
||||||
|
'user' => $u ? [
|
||||||
|
'id' => $u->id,
|
||||||
|
'name' => $u->name,
|
||||||
|
'username' => $u->username,
|
||||||
|
'email' => $u->email,
|
||||||
|
// Avatar: prefer uploaded DB avatar, fall back to OAuth avatar_url
|
||||||
|
'avatar' => $u->avatar,
|
||||||
|
'avatar_url' => $u->avatar_url,
|
||||||
|
'role' => $u->role,
|
||||||
|
'clan_tag' => $u->clan_tag,
|
||||||
|
'vip_level' => (int) ($u->vip_level ?? 0),
|
||||||
|
'balance' => (string) ($u->balance ?? '0'),
|
||||||
|
'stats' => $u->stats ? [
|
||||||
|
'vip_level' => (int) ($u->stats->vip_level ?? 0),
|
||||||
|
'vip_points' => (int) ($u->stats->vip_points ?? 0),
|
||||||
|
] : null,
|
||||||
|
'restrictions' => $u->restrictions()
|
||||||
|
->where('active', true)
|
||||||
|
->where(fn($q) => $q->whereNull('ends_at')->orWhere('ends_at', '>', now()))
|
||||||
|
->get(['type', 'reason', 'ends_at', 'starts_at', 'active']),
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Middleware/MaintenanceModeMiddleware.php
Normal file
50
app/Http/Middleware/MaintenanceModeMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\AppSetting;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class MaintenanceModeMiddleware
|
||||||
|
{
|
||||||
|
private const BYPASS_PATHS = ['up', 'login', 'logout', 'blocked'];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
// Admins always pass through
|
||||||
|
if (Auth::check() && strtolower((string) Auth::user()->role) === 'admin') {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip API webhooks and bypass paths
|
||||||
|
if ($request->is('api/webhooks/*') || $this->shouldBypass($request)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip admin routes for auth
|
||||||
|
if (str_starts_with($request->path(), 'admin')) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = AppSetting::get('site.settings', []);
|
||||||
|
|
||||||
|
if (!empty($settings['maintenance_mode'])) {
|
||||||
|
return Inertia::render('Maintenance', [
|
||||||
|
'message' => 'Wir führen gerade Wartungsarbeiten durch. Bitte komm später zurück.',
|
||||||
|
])->toResponse($request)->setStatusCode(503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldBypass(Request $request): bool
|
||||||
|
{
|
||||||
|
foreach (self::BYPASS_PATHS as $path) {
|
||||||
|
if ($request->is($path) || $request->is($path . '/*')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Http/Middleware/SetLocale.php
Normal file
81
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cookie;
|
||||||
|
|
||||||
|
class SetLocale
|
||||||
|
{
|
||||||
|
/** @var array<string> */
|
||||||
|
private array $available = [
|
||||||
|
'en','de','es','pt_BR','tr','pl',
|
||||||
|
// prepared, not yet enabled in UI
|
||||||
|
'fr','it','ru','uk','vi','id','zh_CN','ja','ko','sv','no','fi','nl',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$locale = $this->resolveLocale($request);
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
app()->setLocale($locale);
|
||||||
|
|
||||||
|
// Persist for guests as well (1 year)
|
||||||
|
$request->session()->put('locale', $locale);
|
||||||
|
Cookie::queue(cookie('locale', $locale, 60 * 24 * 365));
|
||||||
|
|
||||||
|
// If logged in and preference changed, consider persisting upstream (no local DB writes)
|
||||||
|
if ($user = $request->user()) {
|
||||||
|
if (($user->preferred_locale ?? null) !== $locale) {
|
||||||
|
// Defer persistence to external API; keep request-scoped preference only.
|
||||||
|
// Optionally, emit an event or queue a task to sync.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLocale(Request $request): string
|
||||||
|
{
|
||||||
|
// 1) explicit query param ?lang=xx
|
||||||
|
$q = $request->query('lang');
|
||||||
|
if ($q && $this->isAllowed($q)) return $this->normalize($q);
|
||||||
|
|
||||||
|
// 2) user preference
|
||||||
|
$u = $request->user();
|
||||||
|
if ($u && $this->isAllowed($u->preferred_locale ?? null)) return $this->normalize($u->preferred_locale);
|
||||||
|
|
||||||
|
// 3) session
|
||||||
|
$s = $request->session()->get('locale');
|
||||||
|
if ($this->isAllowed($s)) return $this->normalize((string) $s);
|
||||||
|
|
||||||
|
// 4) cookie
|
||||||
|
$c = $request->cookie('locale');
|
||||||
|
if ($this->isAllowed($c)) return $this->normalize((string) $c);
|
||||||
|
|
||||||
|
// 5) Accept-Language best effort
|
||||||
|
$preferred = $request->getPreferredLanguage($this->available);
|
||||||
|
if ($this->isAllowed($preferred)) return $this->normalize((string) $preferred);
|
||||||
|
|
||||||
|
// 6) fallback
|
||||||
|
return config('app.locale', 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isAllowed($code): bool
|
||||||
|
{
|
||||||
|
if (!$code) return false;
|
||||||
|
$norm = $this->normalize((string) $code);
|
||||||
|
return in_array($norm, $this->available, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalize(string $code): string
|
||||||
|
{
|
||||||
|
// Normalize e.g. pt-br → pt_BR, zh-cn → zh_CN; others lower-case
|
||||||
|
$code = str_replace([' ', '-'], ['','_'], trim($code));
|
||||||
|
if (strtolower($code) === 'pt_br') return 'pt_BR';
|
||||||
|
if (strtolower($code) === 'zh_cn') return 'zh_CN';
|
||||||
|
return strtolower($code);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Http/Middleware/ValidateLicenseKey.php
Normal file
46
app/Http/Middleware/ValidateLicenseKey.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Models\OperatorCasino;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ValidateLicenseKey
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Accept the key from X-License-Key header, body field, or query parameter
|
||||||
|
$key = $request->header('X-License-Key')
|
||||||
|
?? $request->input('license_key')
|
||||||
|
?? $request->query('license_key');
|
||||||
|
|
||||||
|
if (!$key) {
|
||||||
|
return response()->json(['error' => 'License key required'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$casino = OperatorCasino::findByKey((string) $key);
|
||||||
|
|
||||||
|
if (!$casino) {
|
||||||
|
return response()->json(['error' => 'Invalid license key'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$casino->isActive()) {
|
||||||
|
return response()->json(['error' => 'Casino license inactive'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP whitelist — skip when the list is empty
|
||||||
|
if (!empty($casino->ip_whitelist)) {
|
||||||
|
$ip = $request->ip();
|
||||||
|
if (!in_array($ip, $casino->ip_whitelist, true)) {
|
||||||
|
return response()->json(['error' => "IP not authorized: {$ip}"], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach casino to request so controllers can use it without re-querying
|
||||||
|
$request->attributes->set('operator_casino', $casino);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Http/Requests/Settings/PasswordUpdateRequest.php
Normal file
25
app/Http/Requests/Settings/PasswordUpdateRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class PasswordUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'current_password' => $this->currentPasswordRules(),
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Http/Requests/Settings/ProfileDeleteRequest.php
Normal file
24
app/Http/Requests/Settings/ProfileDeleteRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\PasswordValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ProfileDeleteRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'password' => $this->currentPasswordRules(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
22
app/Http/Requests/Settings/ProfileUpdateRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Concerns\ProfileValidationRules;
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ProfileUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use ProfileValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return $this->profileRules($this->user()->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
use Laravel\Fortify\InteractsWithTwoFactorState;
|
||||||
|
|
||||||
|
class TwoFactorAuthenticationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
use InteractsWithTwoFactorState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return Features::enabled(Features::twoFactorAuthentication());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
258
app/Mail/SystemNotificationMail.php
Normal file
258
app/Mail/SystemNotificationMail.php
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class SystemNotificationMail extends Mailable implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/** @var array<string,mixed> */
|
||||||
|
public array $data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new message instance.
|
||||||
|
*
|
||||||
|
* @param string $type Machine key for notification type (e.g. deposit, withdrawal, kyc_error)
|
||||||
|
* @param array<string,mixed> $payload Arbitrary data to render inside the template
|
||||||
|
*/
|
||||||
|
public function __construct(public string $type, array $payload = [])
|
||||||
|
{
|
||||||
|
$this->data = $this->buildData($type, $payload);
|
||||||
|
$this->subject($this->data['subject'] ?? (config('app.name') . ' Notification'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the message.
|
||||||
|
*/
|
||||||
|
public function build()
|
||||||
|
{
|
||||||
|
return $this->view('emails.notification')
|
||||||
|
->with($this->data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default content map for supported notification types.
|
||||||
|
*
|
||||||
|
* @return array<string,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
protected function map(): array
|
||||||
|
{
|
||||||
|
$app = config('app.name');
|
||||||
|
return [
|
||||||
|
// Finance
|
||||||
|
'deposit' => [
|
||||||
|
'subject' => "{$app}: Einzahlung eingegangen",
|
||||||
|
'title' => 'Einzahlung bestätigt',
|
||||||
|
'icon' => '💰',
|
||||||
|
'message' => 'Deine Einzahlung wurde erfolgreich verbucht.',
|
||||||
|
'cta' => ['label' => 'Wallet öffnen', 'url' => url('/wallet')],
|
||||||
|
],
|
||||||
|
'withdrawal' => [
|
||||||
|
'subject' => "{$app}: Auszahlung gestartet",
|
||||||
|
'title' => 'Auszahlung in Bearbeitung',
|
||||||
|
'icon' => '🏦',
|
||||||
|
'message' => 'Wir bearbeiten aktuell deine Auszahlung. Du erhältst eine weitere Benachrichtigung, sobald sie abgeschlossen ist.',
|
||||||
|
'cta' => ['label' => 'Verlauf ansehen', 'url' => url('/wallet')],
|
||||||
|
],
|
||||||
|
'bonus_available' => [
|
||||||
|
'subject' => "{$app}: Neuer Bonus verfügbar",
|
||||||
|
'title' => 'Bonus verfügbar',
|
||||||
|
'icon' => '🎁',
|
||||||
|
'message' => 'Es wartet ein neuer Bonus auf dich. Sichere ihn dir, bevor er abläuft!',
|
||||||
|
'cta' => ['label' => 'Bonus holen', 'url' => url('/bonuses')],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Restrictions
|
||||||
|
'banned' => [
|
||||||
|
'subject' => "{$app}: Konto gesperrt",
|
||||||
|
'title' => 'Konto gesperrt',
|
||||||
|
'icon' => '⛔',
|
||||||
|
'message' => 'Dein Konto wurde vorübergehend gesperrt. Bitte kontaktiere den Support für weitere Informationen.',
|
||||||
|
'cta' => ['label' => 'Support kontaktieren', 'url' => url('/support')],
|
||||||
|
],
|
||||||
|
'chat_banned' => [
|
||||||
|
'subject' => "{$app}: Chat eingeschränkt",
|
||||||
|
'title' => 'Chat-Bann',
|
||||||
|
'icon' => '🚫',
|
||||||
|
'message' => 'Dein Chat-Zugang wurde eingeschränkt. Bitte beachte unsere Chat-Richtlinien.',
|
||||||
|
'cta' => ['label' => 'Richtlinien lesen', 'url' => url('/legal/terms')],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Progress
|
||||||
|
'level_up' => [
|
||||||
|
'subject' => "{$app}: Level-Up!",
|
||||||
|
'title' => 'Glückwunsch zum Level-Up',
|
||||||
|
'icon' => '🚀',
|
||||||
|
'message' => 'Du bist ein Level aufgestiegen. Weiter so!',
|
||||||
|
'cta' => ['label' => 'VIP ansehen', 'url' => url('/vip-levels')],
|
||||||
|
],
|
||||||
|
'near_level_up' => [
|
||||||
|
'subject' => "{$app}: Kurz vor Level-Up",
|
||||||
|
'title' => 'Fast geschafft',
|
||||||
|
'icon' => '✨',
|
||||||
|
'message' => 'Du bist kurz davor, das nächste Level zu erreichen. Ein paar Punkte fehlen noch!',
|
||||||
|
'cta' => ['label' => 'Jetzt weiterspielen', 'url' => url('/')],
|
||||||
|
],
|
||||||
|
'inactivity_check' => [
|
||||||
|
'subject' => "{$app}: Vermissen dich! Bist du noch aktiv?",
|
||||||
|
'title' => 'Wir vermissen dich',
|
||||||
|
'icon' => '👋',
|
||||||
|
'message' => 'Wir haben dich eine Weile nicht gesehen. Schau vorbei, es gibt Neuigkeiten und Aktionen!',
|
||||||
|
'cta' => ['label' => 'Zurück zu ' . $app, 'url' => url('/')],
|
||||||
|
],
|
||||||
|
'friend_request' => [
|
||||||
|
'subject' => "{$app}: Neue Freundschaftsanfrage",
|
||||||
|
'title' => 'Freundschaftsanfrage',
|
||||||
|
'icon' => '🤝',
|
||||||
|
'message' => 'Du hast eine neue Freundschaftsanfrage erhalten.',
|
||||||
|
'cta' => ['label' => 'Anfragen ansehen', 'url' => url('/friends')],
|
||||||
|
],
|
||||||
|
|
||||||
|
// KYC
|
||||||
|
'kyc_error' => [
|
||||||
|
'subject' => "{$app}: KYC Fehler",
|
||||||
|
'title' => 'KYC nicht erfolgreich',
|
||||||
|
'icon' => '⚠️',
|
||||||
|
'message' => 'Leider konnte deine Identitätsprüfung nicht abgeschlossen werden. Bitte reiche die benötigten Dokumente erneut ein.',
|
||||||
|
'cta' => ['label' => 'KYC erneut starten', 'url' => url('/settings/kyc')],
|
||||||
|
],
|
||||||
|
'kyc_accepted' => [
|
||||||
|
'subject' => "{$app}: KYC akzeptiert",
|
||||||
|
'title' => 'KYC bestätigt',
|
||||||
|
'icon' => '✅',
|
||||||
|
'message' => 'Deine Identität wurde erfolgreich verifiziert. Vielen Dank!',
|
||||||
|
'cta' => ['label' => 'Zum Dashboard', 'url' => url('/dashboard')],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Security
|
||||||
|
'email_2fa' => [
|
||||||
|
'subject' => "{$app}: 2FA Code",
|
||||||
|
'title' => 'Dein 2FA-Code',
|
||||||
|
'icon' => '🔐',
|
||||||
|
'message' => 'Verwende den folgenden Code, um dich anzumelden oder eine Aktion zu bestätigen.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Policies updates
|
||||||
|
'terms_updated' => [
|
||||||
|
'subject' => "{$app}: Nutzungsbedingungen aktualisiert",
|
||||||
|
'title' => 'Terms & Conditions aktualisiert',
|
||||||
|
'icon' => '📄',
|
||||||
|
'message' => 'Wir haben unsere Nutzungsbedingungen aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Jetzt lesen', 'url' => url('/legal/terms')],
|
||||||
|
],
|
||||||
|
'cookie_policy_updated' => [
|
||||||
|
'subject' => "{$app}: Cookie-Richtlinie aktualisiert",
|
||||||
|
'title' => 'Cookie Policy aktualisiert',
|
||||||
|
'icon' => '🍪',
|
||||||
|
'message' => 'Wir haben unsere Cookie-Richtlinie aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Details ansehen', 'url' => url('/legal/cookies')],
|
||||||
|
],
|
||||||
|
'privacy_policy_updated' => [
|
||||||
|
'subject' => "{$app}: Datenschutz aktualisiert",
|
||||||
|
'title' => 'Privacy Policy aktualisiert',
|
||||||
|
'icon' => '🔏',
|
||||||
|
'message' => 'Wir haben unsere Datenschutzerklärung aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Details ansehen', 'url' => url('/legal/privacy')],
|
||||||
|
],
|
||||||
|
'bonus_policy_updated' => [
|
||||||
|
'subject' => "{$app}: Bonus-Richtlinie aktualisiert",
|
||||||
|
'title' => 'Bonus Policy aktualisiert',
|
||||||
|
'icon' => '🎁',
|
||||||
|
'message' => 'Wir haben unsere Bonus-Richtlinie aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Details ansehen', 'url' => url('/legal/bonus-policy')],
|
||||||
|
],
|
||||||
|
'dispute_policy_updated' => [
|
||||||
|
'subject' => "{$app}: Streitbeilegung aktualisiert",
|
||||||
|
'title' => 'Dispute Resolution aktualisiert',
|
||||||
|
'icon' => '⚖️',
|
||||||
|
'message' => 'Wir haben unsere Richtlinie zur Streitbeilegung aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Details ansehen', 'url' => url('/legal/disputes')],
|
||||||
|
],
|
||||||
|
'responsible_gaming_updated' => [
|
||||||
|
'subject' => "{$app}: Verantwortungsvolles Spielen",
|
||||||
|
'title' => 'Responsible Gaming aktualisiert',
|
||||||
|
'icon' => '🎯',
|
||||||
|
'message' => 'Wir haben unsere Informationen zum verantwortungsvollen Spielen aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Mehr erfahren', 'url' => url('/legal/responsible-gaming')],
|
||||||
|
],
|
||||||
|
'aml_policy_updated' => [
|
||||||
|
'subject' => "{$app}: AML-Policy aktualisiert",
|
||||||
|
'title' => 'AML Policy aktualisiert',
|
||||||
|
'icon' => '🛡️',
|
||||||
|
'message' => 'Wir haben unsere AML-Richtlinie aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Mehr erfahren', 'url' => url('/legal/aml')],
|
||||||
|
],
|
||||||
|
'risk_warnings_updated' => [
|
||||||
|
'subject' => "{$app}: Risikohinweise aktualisiert",
|
||||||
|
'title' => 'Risk Warnings aktualisiert',
|
||||||
|
'icon' => '⚠️',
|
||||||
|
'message' => 'Wir haben unsere Risikohinweise aktualisiert.',
|
||||||
|
'cta' => ['label' => 'Jetzt lesen', 'url' => url('/legal/risk-warnings')],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Support / Misc
|
||||||
|
'new_support_message' => [
|
||||||
|
'subject' => "{$app}: Neue Support-Nachricht",
|
||||||
|
'title' => 'Neue Support-Nachricht',
|
||||||
|
'icon' => '💬',
|
||||||
|
'message' => 'Du hast eine neue Nachricht von unserem Support-Team erhalten.',
|
||||||
|
'cta' => ['label' => 'Support-Chat öffnen', 'url' => url('/support')],
|
||||||
|
],
|
||||||
|
'casino_updated' => [
|
||||||
|
'subject' => "{$app}: Casino aktualisiert",
|
||||||
|
'title' => 'Neue Spiele & Updates',
|
||||||
|
'icon' => '🎲',
|
||||||
|
'message' => 'Es gibt neue Inhalte, Spiele oder Aktionen im Casino. Schau rein! ',
|
||||||
|
'cta' => ['label' => 'Jetzt entdecken', 'url' => url('/')],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build final data array for the template.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
protected function buildData(string $type, array $payload): array
|
||||||
|
{
|
||||||
|
$map = $this->map();
|
||||||
|
$defaults = $map[$type] ?? [
|
||||||
|
'subject' => config('app.name') . ' Notification',
|
||||||
|
'title' => Str::headline(str_replace('_', ' ', $type)),
|
||||||
|
'icon' => '🔔',
|
||||||
|
'message' => 'Es gibt Neuigkeiten zu deinem Konto.',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge with payload overrides
|
||||||
|
$data = array_merge($defaults, $payload);
|
||||||
|
|
||||||
|
// Normalize CTA structure
|
||||||
|
$cta = $data['cta'] ?? null;
|
||||||
|
if (is_string($cta)) {
|
||||||
|
$cta = ['label' => 'Open', 'url' => $cta];
|
||||||
|
}
|
||||||
|
$data['cta'] = is_array($cta) ? $cta : null;
|
||||||
|
|
||||||
|
// Email 2FA code convenience
|
||||||
|
if ($type === 'email_2fa' && empty($data['code']) && !empty($payload['token'])) {
|
||||||
|
$data['code'] = (string) $payload['token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow bullet points list
|
||||||
|
if (!empty($data['bullets']) && is_array($data['bullets'])) {
|
||||||
|
$data['bullets'] = array_values(array_filter($data['bullets'], fn ($v) => filled($v)));
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['type'] = $type;
|
||||||
|
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/AppSetting.php
Normal file
30
app/Models/AppSetting.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class AppSetting extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'app_settings';
|
||||||
|
|
||||||
|
protected $fillable = ['key', 'value'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function get(string $key, $default = null)
|
||||||
|
{
|
||||||
|
$row = static::query()->where('key', $key)->first();
|
||||||
|
return $row?->value ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function put(string $key, $value): void
|
||||||
|
{
|
||||||
|
static::updateOrCreate(['key' => $key], ['value' => $value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/Bonus.php
Normal file
51
app/Models/Bonus.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Bonus extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'type',
|
||||||
|
'amount_value',
|
||||||
|
'amount_unit',
|
||||||
|
'min_deposit',
|
||||||
|
'max_amount',
|
||||||
|
'currency',
|
||||||
|
'code',
|
||||||
|
'status',
|
||||||
|
'starts_at',
|
||||||
|
'expires_at',
|
||||||
|
'rules',
|
||||||
|
'description',
|
||||||
|
'created_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'amount_value' => 'decimal:8',
|
||||||
|
'min_deposit' => 'decimal:8',
|
||||||
|
'max_amount' => 'decimal:8',
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'rules' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
return $query->where('status', 'active')
|
||||||
|
->when($now, function ($q) use ($now) {
|
||||||
|
$q->where(function ($qq) use ($now) {
|
||||||
|
$qq->whereNull('starts_at')->orWhere('starts_at', '<=', $now);
|
||||||
|
})->where(function ($qq) use ($now) {
|
||||||
|
$qq->whereNull('expires_at')->orWhere('expires_at', '>=', $now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Models/ChatMessage.php
Normal file
54
app/Models/ChatMessage.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class ChatMessage extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'message',
|
||||||
|
'reply_to_id',
|
||||||
|
'is_deleted',
|
||||||
|
'deleted_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast attributes.
|
||||||
|
* Encrypt chat messages at rest.
|
||||||
|
*/
|
||||||
|
protected $casts = [
|
||||||
|
'message' => 'encrypted',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replyTo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(self::class, 'reply_to_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replies(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(self::class, 'reply_to_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ChatMessageReaction::class, 'message_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deletedByUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'deleted_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Models/ChatMessageReaction.php
Normal file
27
app/Models/ChatMessageReaction.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ChatMessageReaction extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'message_id',
|
||||||
|
'user_id',
|
||||||
|
'emoji',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ChatMessage::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/ChatMessageReport.php
Normal file
29
app/Models/ChatMessageReport.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ChatMessageReport extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'reporter_id',
|
||||||
|
'message_id',
|
||||||
|
'message_text',
|
||||||
|
'sender_id',
|
||||||
|
'sender_username',
|
||||||
|
'reason',
|
||||||
|
'context_messages',
|
||||||
|
'status',
|
||||||
|
'admin_note',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'context_messages' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function reporter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reporter_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Models/CryptoPayment.php
Normal file
49
app/Models/CryptoPayment.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class CryptoPayment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'order_id',
|
||||||
|
'invoice_id',
|
||||||
|
'payment_id',
|
||||||
|
'pay_currency',
|
||||||
|
'pay_amount',
|
||||||
|
'actually_paid',
|
||||||
|
'pay_address',
|
||||||
|
'price_amount',
|
||||||
|
'price_currency',
|
||||||
|
'exchange_rate_at_payment',
|
||||||
|
'status',
|
||||||
|
'confirmations',
|
||||||
|
'tx_hash',
|
||||||
|
'fee',
|
||||||
|
'raw_payload',
|
||||||
|
'credited_btx',
|
||||||
|
'credited_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'pay_amount' => 'decimal:18',
|
||||||
|
'actually_paid' => 'decimal:18',
|
||||||
|
'price_amount' => 'decimal:8',
|
||||||
|
'exchange_rate_at_payment' => 'decimal:12',
|
||||||
|
'fee' => 'decimal:18',
|
||||||
|
'tx_hash' => 'array',
|
||||||
|
'raw_payload' => 'array',
|
||||||
|
'credited_btx' => 'decimal:8',
|
||||||
|
'credited_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Models/DirectMessage.php
Normal file
38
app/Models/DirectMessage.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DirectMessage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'sender_id',
|
||||||
|
'receiver_id',
|
||||||
|
'message',
|
||||||
|
'reply_to_id',
|
||||||
|
'is_read',
|
||||||
|
'is_deleted',
|
||||||
|
'deleted_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_read' => 'boolean',
|
||||||
|
'is_deleted' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function sender()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'sender_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function receiver()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'receiver_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replyTo()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DirectMessage::class, 'reply_to_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/DirectMessageReport.php
Normal file
26
app/Models/DirectMessageReport.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class DirectMessageReport extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'reporter_id',
|
||||||
|
'message_id',
|
||||||
|
'reason',
|
||||||
|
'details',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function reporter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reporter_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function message()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(DirectMessage::class, 'message_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/Friend.php
Normal file
23
app/Models/Friend.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Friend extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'friend_id', 'status'];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function friend()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'friend_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Models/GameBet.php
Normal file
24
app/Models/GameBet.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class GameBet extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $guarded = [];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'wager_amount' => 'decimal:8',
|
||||||
|
'payout_multiplier' => 'decimal:4',
|
||||||
|
'payout_amount' => 'decimal:8',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Models/Guild.php
Normal file
43
app/Models/Guild.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class Guild extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'tag',
|
||||||
|
'logo_url',
|
||||||
|
'description',
|
||||||
|
'owner_id',
|
||||||
|
'invite_code',
|
||||||
|
'points',
|
||||||
|
'members_count',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der User, dem die Gilde gehört.
|
||||||
|
*/
|
||||||
|
public function owner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'owner_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Mitglieder der Gilde (Verknüpfung zu Users über die Pivot-Tabelle).
|
||||||
|
* Dies ermöglicht die Nutzung von attach(), detach() und sync().
|
||||||
|
*/
|
||||||
|
public function members(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(User::class, 'guild_members')
|
||||||
|
->withPivot('role', 'joined_at')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Models/GuildMember.php
Normal file
34
app/Models/GuildMember.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class GuildMember extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'guild_id',
|
||||||
|
'user_id',
|
||||||
|
'role', // owner, admin, member
|
||||||
|
'wagered',
|
||||||
|
'joined_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'joined_at' => 'datetime',
|
||||||
|
'wagered' => 'decimal:4',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function guild()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Guild::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/GuildMessage.php
Normal file
36
app/Models/GuildMessage.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class GuildMessage extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'guild_id',
|
||||||
|
'user_id',
|
||||||
|
'type',
|
||||||
|
'message',
|
||||||
|
'reply_to_id',
|
||||||
|
'is_deleted',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_deleted' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function guild()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Guild::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function replyTo()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GuildMessage::class, 'reply_to_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Models/KycDocument.php
Normal file
37
app/Models/KycDocument.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class KycDocument extends Model
|
||||||
|
{
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'category',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'rejection_reason',
|
||||||
|
'file_path',
|
||||||
|
'mime',
|
||||||
|
'size',
|
||||||
|
'submitted_at',
|
||||||
|
'reviewed_at',
|
||||||
|
'reviewed_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'submitted_at' => 'datetime',
|
||||||
|
'reviewed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Models/OperatorCasino.php
Normal file
40
app/Models/OperatorCasino.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class OperatorCasino extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'license_key_hash',
|
||||||
|
'status',
|
||||||
|
'ip_whitelist',
|
||||||
|
'domain_whitelist',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'ip_whitelist' => 'array',
|
||||||
|
'domain_whitelist' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up a casino by its plaintext license key.
|
||||||
|
* Hashes the key and queries by hash — plaintext is never stored.
|
||||||
|
*/
|
||||||
|
public static function findByKey(string $key): ?self
|
||||||
|
{
|
||||||
|
return static::where('license_key_hash', hash('sha256', $key))->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isActive(): bool
|
||||||
|
{
|
||||||
|
return $this->status === 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sessions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(OperatorSession::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Models/OperatorSession.php
Normal file
52
app/Models/OperatorSession.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class OperatorSession extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'session_token',
|
||||||
|
'operator_casino_id',
|
||||||
|
'player_id',
|
||||||
|
'game_slug',
|
||||||
|
'currency',
|
||||||
|
'start_balance',
|
||||||
|
'current_balance',
|
||||||
|
'server_seed',
|
||||||
|
'server_seed_hash',
|
||||||
|
'client_seed',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'start_balance' => 'decimal:4',
|
||||||
|
'current_balance' => 'decimal:4',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function casino()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(OperatorCasino::class, 'operator_casino_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at->isPast() || $this->status !== 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark expired session if its expiry timestamp has passed.
|
||||||
|
* Returns true if the status was just changed.
|
||||||
|
*/
|
||||||
|
public function expireIfNeeded(): bool
|
||||||
|
{
|
||||||
|
if ($this->status === 'active' && $this->expires_at->isPast()) {
|
||||||
|
$this->update(['status' => 'expired']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/ProfileComment.php
Normal file
23
app/Models/ProfileComment.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProfileComment extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'profile_id', 'content'];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'profile_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/ProfileLike.php
Normal file
23
app/Models/ProfileLike.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProfileLike extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['user_id', 'profile_id'];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'profile_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Models/ProfileReport.php
Normal file
27
app/Models/ProfileReport.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProfileReport extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = ['reporter_id', 'profile_id', 'reason', 'details', 'snapshot', 'screenshot_path', 'status', 'admin_note'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'snapshot' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function reporter()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'reporter_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profile()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'profile_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Models/Promo.php
Normal file
48
app/Models/Promo.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Promo extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'code',
|
||||||
|
'description',
|
||||||
|
'bonus_amount',
|
||||||
|
'wager_multiplier',
|
||||||
|
'per_user_limit',
|
||||||
|
'global_limit',
|
||||||
|
'starts_at',
|
||||||
|
'ends_at',
|
||||||
|
'min_deposit',
|
||||||
|
'bonus_expires_days',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'bonus_amount' => 'decimal:4',
|
||||||
|
'min_deposit' => 'decimal:4',
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'ends_at' => 'datetime',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'bonus_expires_days' => 'integer',
|
||||||
|
'wager_multiplier' => 'integer',
|
||||||
|
'per_user_limit' => 'integer',
|
||||||
|
'global_limit' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function usages(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PromoUsage::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function userBonuses(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserBonus::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Models/PromoUsage.php
Normal file
34
app/Models/PromoUsage.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PromoUsage extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'promo_id',
|
||||||
|
'user_id',
|
||||||
|
'used_at',
|
||||||
|
'ip',
|
||||||
|
'user_agent',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'used_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function promo(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Promo::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Models/Tip.php
Normal file
19
app/Models/Tip.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Tip extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'from_user_id',
|
||||||
|
'to_user_id',
|
||||||
|
'currency',
|
||||||
|
'amount',
|
||||||
|
'note',
|
||||||
|
];
|
||||||
|
}
|
||||||
303
app/Models/User.php
Normal file
303
app/Models/User.php
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
// use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
use App\Casts\EncryptedDecimal;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use App\Notifications\VerifyEmail;
|
||||||
|
use App\Notifications\ResetPassword;
|
||||||
|
use App\Casts\SafeEncryptedString;
|
||||||
|
use Illuminate\Encryption\Encrypter;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\Crypt;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that are mass assignable.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'email_index', // Blind Index for lookups
|
||||||
|
'username',
|
||||||
|
'username_index', // Blind Index for lookups
|
||||||
|
'avatar_url', // Profile image for chat/header
|
||||||
|
'role', // Role badge (e.g., Admin, Streamer, Co)
|
||||||
|
'clan_tag', // Clan short tag badge
|
||||||
|
// Profile fields
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'gender',
|
||||||
|
'birthdate',
|
||||||
|
'phone',
|
||||||
|
'country',
|
||||||
|
'address_line1',
|
||||||
|
'address_line2',
|
||||||
|
'city',
|
||||||
|
'state',
|
||||||
|
'postal_code',
|
||||||
|
'currency',
|
||||||
|
'is_adult',
|
||||||
|
'password',
|
||||||
|
'last_login_at',
|
||||||
|
'last_login_ip',
|
||||||
|
'last_login_user_agent',
|
||||||
|
'balance', // BTX Balance
|
||||||
|
'vip_level',
|
||||||
|
'vault_balance',
|
||||||
|
'vault_balances',
|
||||||
|
'preferred_locale',
|
||||||
|
// Social Profile Fields
|
||||||
|
'is_public',
|
||||||
|
'bio',
|
||||||
|
'avatar',
|
||||||
|
'banner',
|
||||||
|
'is_banned', // Added
|
||||||
|
'withdraw_cooldown_until',
|
||||||
|
'registration_ip',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The attributes that should be hidden for serialization.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
protected $hidden = [
|
||||||
|
'password',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
|
'remember_token',
|
||||||
|
'birthdate', // Hide sensitive data
|
||||||
|
'phone', // Hide sensitive data
|
||||||
|
'email_index',
|
||||||
|
'username_index',
|
||||||
|
'last_login_ip',
|
||||||
|
'last_login_user_agent',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email_verified_at' => 'datetime',
|
||||||
|
'password' => 'hashed',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
|
'email' => SafeEncryptedString::class,
|
||||||
|
'is_adult' => 'boolean',
|
||||||
|
'last_login_at' => 'datetime',
|
||||||
|
'balance' => 'decimal:4',
|
||||||
|
'vip_level' => 'integer',
|
||||||
|
'vault_balance' => 'decimal:4', // Store and handle as plaintext decimal now
|
||||||
|
'vault_balances' => 'array', // JSON map: {"BTC":"0","ETH":"0","SOL":"0"}
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
'is_banned' => 'boolean', // Added
|
||||||
|
// Vault PIN-related
|
||||||
|
'vault_pin_set_at' => 'datetime',
|
||||||
|
'vault_pin_locked_until' => 'datetime',
|
||||||
|
'vault_pin_attempts' => 'integer',
|
||||||
|
'withdraw_cooldown_until' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically hash email and username for blind indexing on save.
|
||||||
|
*/
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saving(function (User $user) {
|
||||||
|
if ($user->isDirty('email')) {
|
||||||
|
// Store email hash in lowercase for case-insensitive lookup
|
||||||
|
$user->email_index = hash('sha256', strtolower($user->email));
|
||||||
|
}
|
||||||
|
if ($user->isDirty('username')) {
|
||||||
|
// Store username hash in lowercase for case-insensitive lookup
|
||||||
|
$user->username_index = hash('sha256', strtolower($user->username));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's stats.
|
||||||
|
*/
|
||||||
|
public function stats()
|
||||||
|
{
|
||||||
|
return $this->hasOne(UserStats::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's wallets.
|
||||||
|
*/
|
||||||
|
public function wallets()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Wallet::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's guild membership.
|
||||||
|
*/
|
||||||
|
public function guildMember()
|
||||||
|
{
|
||||||
|
return $this->hasOne(GuildMember::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all restrictions for the user.
|
||||||
|
*/
|
||||||
|
public function restrictions()
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserRestriction::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user has an active account ban.
|
||||||
|
*
|
||||||
|
* @return \Illuminate\Database\Eloquent\Casts\Attribute
|
||||||
|
*/
|
||||||
|
protected function isBanned(): \Illuminate\Database\Eloquent\Casts\Attribute
|
||||||
|
{
|
||||||
|
return \Illuminate\Database\Eloquent\Casts\Attribute::make(
|
||||||
|
get: fn () => $this->restrictions()->where('type', 'account_ban')->where('active', true)->exists(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the email address that should be used for password reset.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getEmailForPasswordReset()
|
||||||
|
{
|
||||||
|
return $this->email; // Use the actual email
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the email verification notification.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function sendEmailVerificationNotification()
|
||||||
|
{
|
||||||
|
$this->notify(new VerifyEmail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the password reset notification.
|
||||||
|
*
|
||||||
|
* @param string $token
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function sendPasswordResetNotification($token)
|
||||||
|
{
|
||||||
|
$this->notify(new ResetPassword($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure MailChannel gets a plaintext, RFC-compliant email address.
|
||||||
|
* If the stored value is still encrypted/malformed, try to decrypt
|
||||||
|
* using configured keys. If still invalid, log and return null to skip send.
|
||||||
|
*
|
||||||
|
* Returning null will prevent the Mail channel from sending and avoids
|
||||||
|
* Symfony RfcComplianceException while we fix upstream data/keys.
|
||||||
|
*
|
||||||
|
* @return string|array|null
|
||||||
|
*/
|
||||||
|
public function routeNotificationForMail(): string|array|null
|
||||||
|
{
|
||||||
|
// 1) Try the casted value first (should be plaintext if keys are aligned)
|
||||||
|
$candidates = [];
|
||||||
|
$casted = $this->email;
|
||||||
|
if (is_string($casted)) {
|
||||||
|
$candidates[] = $casted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Try decrypting the raw DB value explicitly if it looks encrypted
|
||||||
|
$raw = (string) $this->getRawOriginal('email');
|
||||||
|
if ($raw !== '') {
|
||||||
|
$candidates[] = $raw;
|
||||||
|
$decrypted = $this->tryDecrypt($raw);
|
||||||
|
if ($decrypted !== null) {
|
||||||
|
$candidates[] = $decrypted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) As last resort, try decrypting the casted value too (in case a layer re-wrapped it)
|
||||||
|
if (is_string($casted)) {
|
||||||
|
$dec2 = $this->tryDecrypt($casted);
|
||||||
|
if ($dec2 !== null) {
|
||||||
|
$candidates[] = $dec2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($candidates as $value) {
|
||||||
|
if (is_string($value) && $this->isValidEmail($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid address could be obtained. Log once per user session/context.
|
||||||
|
Log::warning('Unable to derive plaintext email for mail routing; skipping send to avoid RFC error', [
|
||||||
|
'user_id' => $this->id,
|
||||||
|
'env' => app()->environment(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// In local/dev, you may prefer to route to MAIL_FROM_ADDRESS to unblock flows:
|
||||||
|
if (app()->environment(['local', 'development'])) {
|
||||||
|
$fallback = (string) config('mail.from.address');
|
||||||
|
if ($this->isValidEmail($fallback)) {
|
||||||
|
return $fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returning null prevents MailChannel from attempting a send
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tryDecrypt(?string $value): ?string
|
||||||
|
{
|
||||||
|
if (!is_string($value) || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!$this->looksEncrypted($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return Crypt::decryptString($value);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isValidEmail(string $email): bool
|
||||||
|
{
|
||||||
|
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function looksEncrypted(string $value): bool
|
||||||
|
{
|
||||||
|
$decoded = base64_decode($value, true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$json = json_decode($decoded, true);
|
||||||
|
if (!is_array($json)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isset($json['iv'], $json['value'], $json['mac']);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/UserAchievement.php
Normal file
23
app/Models/UserAchievement.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class UserAchievement extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'achievement_key',
|
||||||
|
'unlocked_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'unlocked_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user