Initialer Laravel Commit für BetiX
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
2026-04-04 18:01:50 +02:00
commit 0280278978
374 changed files with 65210 additions and 0 deletions

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

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
public-hoist-pattern[]=@inertiajs/core

2
.prettierignore Normal file
View File

@@ -0,0 +1,2 @@
resources/js/components/ui/*
resources/views/mail/*

25
.prettierrc Normal file
View 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
}
}
]
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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