Initialer Laravel Commit für BetiX
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled

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