feat: add app and database modules

This commit is contained in:
2026-05-21 16:05:11 +07:00
parent 37b7e783f5
commit fad70d096b
212 changed files with 23901 additions and 0 deletions
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Spatie\Permission\Models\Permission;
class CheckActivePermission
{
public function handle(Request $request, Closure $next, $permission)
{
$cacheKey = "permission_status:{$permission}";
$isActive = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($permission) {
$permissionModel = Permission::where('name', $permission)->first();
return $permissionModel && $permissionModel->is_active;
});
// If permission not found OR inactive -> deny access
if (! $isActive) {
abort(403, 'This permission is inactive or not available.');
}
// Continue request (permission is active)
return $next($request);
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class CheckLegalAgreement
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$user = Auth::user();
// Skip for guests
if (! $user) {
return $next($request);
}
// Avoid infinite redirect loop; also skip auth verification routes
if ($request->routeIs('legal.*', 'verification.*', 'password.*') || $request->is('logout')) {
return $next($request);
}
// Check if user has agreed to current ToS and PDP versions
if (! $user->hasAgreedToCurrentLegal('tos') || ! $user->hasAgreedToCurrentLegal('privacy')) {
return redirect()->route('legal.re-agree');
}
return $next($request);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware: CheckMenuPermission
*
* Protects parent menu route groups.
* Allows access if the user has legacy menu-level permissions OR at least
* one granular scoped tab permission within the given menu.
*
* Route usage:
* ->middleware('menu-permission:global settings')
* ->middleware('menu-permission:mobile settings,manage')
*/
class CheckMenuPermission
{
public function handle(Request $request, Closure $next, string $menu, string $action = 'view'): Response
{
if (! auth()->check()) {
return $request->expectsJson()
? response()->json(['message' => 'Unauthenticated.'], 401)
: redirect()->route('login');
}
$allowed = $action === 'manage'
? can_manage_any_tab($menu)
: can_view_any_tab($menu);
if (! $allowed) {
return $request->expectsJson()
? response()->json(['message' => 'This action is unauthorized.'], 403)
: abort(403, "Access denied to menu: {$menu}");
}
return $next($request);
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Middleware: CheckTabPermission
*
* Protects individual tab/section endpoints within a menu page.
* Checks both scoped (e.g. "view global settings:login-security") and
* legacy menu-level ("manage global settings") permissions.
*
* Route usage:
* ->middleware('tab-permission:global settings,login-security')
* ->middleware('tab-permission:mobile settings,branding,manage')
*
* Parameters:
* $menu — the menu slug, e.g. "global settings"
* $tab — the tab slug, e.g. "login-security"
* $action — "view" (default) or "manage"
*/
class CheckTabPermission
{
public function handle(Request $request, Closure $next, string $menu, string $tab, string $action = 'view'): Response
{
if (! auth()->check()) {
return $request->expectsJson()
? response()->json(['message' => 'Unauthenticated.'], 401)
: redirect()->route('login');
}
$allowed = $action === 'manage'
? can_manage_tab($menu, $tab)
: can_view_tab($menu, $tab);
if (! $allowed) {
return $request->expectsJson()
? response()->json(['message' => 'This action is unauthorized.'], 403)
: abort(403, "Access denied to tab: {$tab}");
}
return $next($request);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class GzipCompression
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (
$response instanceof StreamedResponse ||
$response instanceof BinaryFileResponse
) {
return $response;
}
if (
extension_loaded('zlib') &&
str_contains($request->header('Accept-Encoding'), 'gzip') &&
function_exists('gzencode') &&
! $response->headers->has('Content-Encoding')
) {
$content = $response->getContent();
$compressedContent = gzencode($content, 6);
if ($compressedContent !== false) {
$response->setContent($compressedContent);
$response->headers->add([
'Content-Encoding' => 'gzip',
'Content-Length' => strlen($compressedContent),
'X-Compressed' => 'true',
]);
}
}
return $response;
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Middleware;
use App\Services\Notification\TelegramService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;
class IpAccessControl
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$ip = $request->ip();
// Batch get common settings to reduce function overhead
$settings = [
'blacklist' => get_setting('ip_blacklist', ''),
'whitelist_admin' => get_setting('ip_whitelist_admin', ''),
'auto_block' => get_setting('auto_block_ip', false),
'single_session' => get_setting('session_single_session', false),
'hsts' => get_setting('hsts_enabled', false),
];
// 1. GLOBAL BLACKLIST
$blacklistArr = array_filter(array_map('trim', explode(',', $settings['blacklist'])));
if (in_array($ip, $blacklistArr)) {
abort(403, 'Your IP address has been blocked.');
}
// 2. ADMIN WHITELIST (Protects specific routes)
// Check if current route is an admin/restricted route
if ($request->is('system-config*') || $request->is('users*') || $request->is('roles*') || $request->is('permissions*') || $request->is('backups*') || $request->is('admin/*')) {
$whitelistArr = array_filter(array_map('trim', explode(',', $settings['whitelist_admin'])));
if (! empty($whitelistArr) && ! in_array($ip, $whitelistArr)) {
abort(403, 'Access denied: Admin IP Whitelist restricted.');
}
}
// 3. RATE LIMITING & AUTO BLOCK
if ($settings['auto_block']) {
$cacheKey = "ip_block:{$ip}";
if (Cache::has($cacheKey)) {
abort(429, 'Your IP has been temporarily blocked due to excessive requests.');
}
$threshold = get_setting('threshold_auto_block', 100);
$hitKey = "ip_hits:{$ip}";
Cache::add($hitKey, 0, now()->addMinute());
$hits = Cache::increment($hitKey);
if ($hits > $threshold) {
Cache::put($cacheKey, true, now()->addHours(24));
// 🚨 Send Security Alert to Telegram
try {
$telegram = app(TelegramService::class);
$msg = "<b>[FIREWALL BLOCK]</b>\n\n";
$msg .= "IP Address: <code>{$ip}</code>\n";
$msg .= "Reason: <b>Excessive Requests</b> ({$hits} hits)\n";
$msg .= "Action: <b>Auto-Blocked (24h)</b>\n\n";
$msg .= "Check configuration: <a href='".url('/system-config')."'>Admin Panel</a>";
$telegram->sendMessage($msg);
} catch (\Exception $e) {
\Log::error('Firewall Telegram Alert Failed: '.$e->getMessage());
}
abort(429, 'Excessive requests detected. Your IP has been blocked for 24 hours.');
}
}
// 4. SINGLE SESSION ENFORCEMENT
// Skip check if we are currently impersonating to prevent logout
if ($request->user() && $settings['single_session'] && ! session()->has('impersonator_id')) {
if ($request->user()->last_session_id !== session()->getId()) {
Auth::logout();
return redirect()->route('login')->with('error', 'You have been logged out because another device logged into your account.');
}
}
$response = $next($request);
// 5. HSTS (Transport Security)
if ($request->isSecure() && $settings['hsts']) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Middleware;
use App\Services\MobileConfig\MobileConfigService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class MobileMaintenanceMiddleware
{
public function __construct(
protected MobileConfigService $service
) {}
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Only apply to mobile API v1 routes
if (! $request->is('api/v1/*')) {
return $next($request);
}
$config = $this->service->all();
$control = $config['control_center'] ?? [];
$updates = $config['app_updates'] ?? [];
// 1. Check KILL SWITCH (Emergency Maintenance)
if (filter_var($control['kill_switch_active'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
// Check IP Bypass
$bypassIps = array_map('trim', explode(',', $control['maintenance_bypass_ips'] ?? ''));
if (! in_array($request->ip(), $bypassIps)) {
return response()->json([
'status' => 'maintenance',
'message' => $control['kill_switch_message'] ?? 'System is currently undergoing emergency maintenance.',
'maintenance_info' => [
'start' => $control['maintenance_start_at'] ?? null,
'end' => $control['maintenance_end_at'] ?? null,
],
], 503);
}
}
// 2. Check APP VERSION (Force Update)
$clientVersion = $request->query('v') ?: $request->header('X-App-Version');
$minVersion = $updates['min_app_version'] ?? '1.0.0';
if ($clientVersion && version_compare($clientVersion, $minVersion, '<')) {
return response()->json([
'status' => 'upgrade_required',
'message' => 'A mandatory update is required to continue using the application.',
'current_version' => $clientVersion,
'required_version' => $minVersion,
'update_urls' => [
'android' => $updates['store_url_android'] ?? null,
'ios' => $updates['store_url_ios'] ?? null,
],
], 426); // 426 Upgrade Required
}
return $next($request);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Services\Auth\PasswordPolicyService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class PasswordExpiryMiddleware
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
$user = Auth::user();
// Skip for specific routes to avoid infinite loops
if ($request->is('profile/password*') || $request->is('logout')) {
return $next($request);
}
if (PasswordPolicyService::isPasswordExpired($user)) {
return redirect()->route('profile.edit')
->with('warning', __('Your password has expired. Please update it to continue using the application.'));
}
}
return $next($request);
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SecurityHeaders
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// 🛡️ SECURITY HEADERS
// Prevent Clickjacking
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
// Prevent Mime-Type Sniffing
$response->headers->set('X-Content-Type-Options', 'nosniff');
// Cross-Site Scripting (XSS) Protection (for older browsers)
$response->headers->set('X-XSS-Protection', '1; mode=block');
// Referrer Policy
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Permissions Policy
$response->headers->set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
// Content Security Policy — enforced (current CDN stack requires unsafe-inline/eval)
$cdnSources = 'https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://cdn.ckeditor.com https://unpkg.com https://code.jquery.com https://www.google.com https://www.gstatic.com';
$csp = implode('; ', [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' {$cdnSources}",
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://unpkg.com https://fonts.googleapis.com",
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"img-src 'self' data: blob: https:",
"connect-src 'self' https://o*.ingest.sentry.io wss:",
"frame-src 'self' https://www.google.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
]);
$response->headers->set('Content-Security-Policy', $csp);
// Report-Only: stricter policy (no unsafe-inline/eval) — violations logged in browser
// console without blocking users. Use this to audit inline scripts before tightening
// the enforced policy above.
$cspReportOnly = implode('; ', [
"default-src 'self'",
"script-src 'self' {$cdnSources}",
"style-src 'self' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.datatables.net https://unpkg.com https://fonts.googleapis.com",
"font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com",
"img-src 'self' data: blob: https:",
"connect-src 'self' https://o*.ingest.sentry.io wss:",
"frame-src 'self' https://www.google.com",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
]);
$response->headers->set('Content-Security-Policy-Report-Only', $cspReportOnly);
// Strict-Transport-Security (Only if HTTPS)
if ($request->isSecure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}