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
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*/
namespace App\Services\AI;
class AiAssistantService
{
public function __construct(
protected AiService $aiService
) {}
/**
* Answer questions about the system.
*/
public function answer(string $question): string
{
if (! get_setting('ai_enabled', false)) {
return 'AI Assistant is currently disabled.';
}
$systemPrompt = "You are the BIIProject Intelligent Assistant.
You help administrators manage the system.
The system is built with Laravel, PostgreSQL, and Redis.
Key features: RBAC, System Monitoring, AI Log Analysis, Backup/Restore.
Guidelines:
- Be professional, helpful, and concise.
- If asked about technical details, provide accurate Laravel-based advice.
- If you don't know the specific configuration of this instance, advise checking 'System Config'.
- Security is priority. Never advise actions that compromise security.";
try {
$result = $this->aiService->provider()->generate($question, [
'system_instruction' => $systemPrompt,
]);
if (isset($result['success']) && $result['success']) {
return $result['response'];
}
return 'Sorry, I encountered an error: '.($result['error'] ?? 'Unknown error');
} catch (\Exception $e) {
return 'Error: '.$e->getMessage();
}
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Services\AI;
interface AiProviderInterface
{
/**
* Generate response from the AI provider.
*
* @return array [success => bool, response => string, usage => array, error => string]
*/
public function generate(string $prompt, array $options = []): array;
/**
* Get the provider identifier.
*/
public function getIdentifier(): string;
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\AI;
use App\Services\SystemConfig\SystemConfigService;
class AiService
{
protected $systemConfig;
protected $providers = [];
public function __construct(SystemConfigService $systemConfig)
{
$this->systemConfig = $systemConfig;
}
/**
* Get a provider instance.
*/
public function provider(?string $identifier = null): AiProviderInterface
{
$identifier = $identifier ?: $this->systemConfig->get('ai_provider', 'gpt');
if (isset($this->providers[$identifier])) {
return $this->providers[$identifier];
}
$config = $this->getProviderConfig($identifier);
$instance = match ($identifier) {
'gpt' => new GptProvider($config),
'gemini' => new GeminiProvider($config),
'claude' => new ClaudeProvider($config),
'deepseek' => new OpenAiCompatibleProvider('deepseek', 'https://api.deepseek.com/chat/completions', $config),
'grok' => new OpenAiCompatibleProvider('grok', 'https://api.x.ai/v1/chat/completions', $config),
'mistral' => new OpenAiCompatibleProvider('mistral', 'https://api.mistral.ai/v1/chat/completions', $config),
'openrouter' => new OpenAiCompatibleProvider('openrouter', 'https://openrouter.ai/api/v1/chat/completions', $config),
'ollama' => new OllamaProvider($config),
default => throw new \Exception("Unsupported AI provider: {$identifier}"),
};
return $this->providers[$identifier] = $instance;
}
/**
* Get configuration for a specific provider from system settings.
*/
protected function getProviderConfig(string $identifier): array
{
return [
'key' => $this->systemConfig->get("ai_{$identifier}_key"),
'base_url' => $this->systemConfig->get("ai_{$identifier}_base_url"),
'default_model' => $this->systemConfig->get('ai_default_model'),
'instruction' => $this->systemConfig->get('ai_system_instruction'),
'temperature' => $this->systemConfig->get('ai_temperature', 0.7),
'max_tokens' => $this->systemConfig->get('ai_max_tokens', 2000),
];
}
/**
* Static method to get supported models for each provider.
*/
public static function getSupportedModels(): array
{
return [
'gpt' => [
['id' => 'gpt-4o', 'name' => 'GPT-4o (Newest)'],
['id' => 'gpt-4-turbo', 'name' => 'GPT-4 Turbo'],
['id' => 'gpt-3.5-turbo', 'name' => 'GPT-3.5 Turbo'],
],
'gemini' => [
['id' => 'gemini-1.5-pro', 'name' => 'Gemini 1.5 Pro'],
['id' => 'gemini-1.5-flash', 'name' => 'Gemini 1.5 Flash'],
['id' => 'gemini-1.0-pro', 'name' => 'Gemini 1.0 Pro'],
],
'claude' => [
['id' => 'claude-3-5-sonnet-20240620', 'name' => 'Claude 3.5 Sonnet'],
['id' => 'claude-3-opus-20240229', 'name' => 'Claude 3 Opus'],
['id' => 'claude-3-sonnet-20240229', 'name' => 'Claude 3 Sonnet'],
['id' => 'claude-3-haiku-20240307', 'name' => 'Claude 3 Haiku'],
],
'deepseek' => [
['id' => 'deepseek-chat', 'name' => 'DeepSeek Chat'],
['id' => 'deepseek-coder', 'name' => 'DeepSeek Coder'],
],
'grok' => [
['id' => 'grok-1', 'name' => 'Grok-1'],
],
'mistral' => [
['id' => 'mistral-large-latest', 'name' => 'Mistral Large'],
['id' => 'mistral-medium-latest', 'name' => 'Mistral Medium'],
['id' => 'mistral-small-latest', 'name' => 'Mistral Small'],
],
'openrouter' => [
['id' => 'google/gemini-pro-1.5', 'name' => 'Gemini Pro 1.5'],
['id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Claude 3.5 Sonnet'],
['id' => 'meta-llama/llama-3-70b-instruct', 'name' => 'Llama 3 70B'],
],
'ollama' => [
['id' => 'llama3', 'name' => 'Llama 3'],
['id' => 'mistral', 'name' => 'Mistral'],
['id' => 'phi3', 'name' => 'Phi-3'],
],
];
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Services\AI;
use App\Models\AI\AiUsageLog;
use Illuminate\Support\Facades\Auth;
abstract class BaseAiProvider implements AiProviderInterface
{
protected $config;
public function __construct(array $config = [])
{
$this->config = $config;
}
/**
* Log the AI usage to the database.
*/
protected function logUsage(array $data): void
{
try {
AiUsageLog::create([
'user_id' => Auth::id(),
'provider' => $this->getIdentifier(),
'model' => $data['model'] ?? 'unknown',
'prompt' => $data['prompt'] ?? null,
'response' => $data['response'] ?? null,
'prompt_tokens' => $data['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $data['usage']['completion_tokens'] ?? 0,
'total_tokens' => $data['usage']['total_tokens'] ?? 0,
'estimated_cost' => $this->calculateCost($data),
'status' => $data['status'] ?? 'success',
'error_message' => $data['error'] ?? null,
'metadata' => $data['metadata'] ?? null,
]);
} catch (\Exception $e) {
\Log::error('Failed to log AI usage: '.$e->getMessage());
}
}
/**
* Abstract cost calculation, to be implemented by each provider.
*/
protected function calculateCost(array $data): float
{
// Default implementation, can be overridden
return 0.0;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
class ClaudeProvider extends BaseAiProvider
{
public function getIdentifier(): string
{
return 'claude';
}
public function generate(string $prompt, array $options = []): array
{
$key = $options['key'] ?? $this->config['key'] ?? null;
if (! $key) {
return ['success' => false, 'error' => 'API Key not configured.'];
}
$model = $options['model'] ?? $this->config['default_model'] ?? 'claude-3-5-sonnet-20240620';
$instruction = $options['instruction'] ?? $this->config['instruction'] ?? '';
$startTime = microtime(true);
try {
$res = Http::timeout(60)->withHeaders([
'x-api-key' => $key,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000),
'system' => $instruction,
'messages' => [
['role' => 'user', 'content' => $prompt],
],
'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7),
]);
if ($res->failed()) {
$error = $res->json()['error']['message'] ?? 'Claude Error';
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'status' => 'failed',
'error' => $error,
]);
return ['success' => false, 'error' => $error];
}
$data = $res->json();
$response = $data['content'][0]['text'];
$usage = [
'prompt_tokens' => $data['usage']['input_tokens'] ?? 0,
'completion_tokens' => $data['usage']['output_tokens'] ?? 0,
'total_tokens' => ($data['usage']['input_tokens'] ?? 0) + ($data['usage']['output_tokens'] ?? 0),
];
$result = [
'success' => true,
'response' => $response,
'usage' => $usage,
'model' => $model,
'latency' => microtime(true) - $startTime,
];
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'response' => $response,
'usage' => $usage,
'status' => 'success',
'metadata' => ['latency' => $result['latency']],
]);
return $result;
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
class GeminiProvider extends BaseAiProvider
{
public function getIdentifier(): string
{
return 'gemini';
}
public function generate(string $prompt, array $options = []): array
{
$key = $options['key'] ?? $this->config['key'] ?? null;
if (! $key) {
return ['success' => false, 'error' => 'API Key not configured.'];
}
$model = $options['model'] ?? $this->config['default_model'] ?? 'gemini-1.5-flash';
$instruction = $options['instruction'] ?? $this->config['instruction'] ?? '';
$startTime = microtime(true);
try {
$res = Http::timeout(60)->post("https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$key}", [
'contents' => [
['parts' => [['text' => $instruction."\n\n".$prompt]]],
],
'generationConfig' => [
'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7),
'maxOutputTokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000),
],
]);
if ($res->failed()) {
$error = $res->json()['error']['message'] ?? 'Gemini Error';
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'status' => 'failed',
'error' => $error,
]);
return ['success' => false, 'error' => $error];
}
$data = $res->json();
$response = $data['candidates'][0]['content']['parts'][0]['text'] ?? 'No response';
// Gemini doesn't always return token count in the same way, simplified for now
$usage = [
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
];
$result = [
'success' => true,
'response' => $response,
'usage' => $usage,
'model' => $model,
'latency' => microtime(true) - $startTime,
];
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'response' => $response,
'usage' => $usage,
'status' => 'success',
'metadata' => ['latency' => $result['latency']],
]);
return $result;
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
class GptProvider extends BaseAiProvider
{
public function getIdentifier(): string
{
return 'gpt';
}
public function generate(string $prompt, array $options = []): array
{
$key = $options['key'] ?? $this->config['key'] ?? null;
if (! $key) {
return ['success' => false, 'error' => 'API Key not configured.'];
}
$model = $options['model'] ?? $this->config['default_model'] ?? 'gpt-4o';
$instruction = $options['instruction'] ?? $this->config['instruction'] ?? '';
$startTime = microtime(true);
try {
$res = Http::withToken($key)
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'messages' => [
['role' => 'system', 'content' => $instruction],
['role' => 'user', 'content' => $prompt],
],
'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7),
'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000),
]);
if ($res->failed()) {
$error = $res->json()['error']['message'] ?? 'OpenAI Error';
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'status' => 'failed',
'error' => $error,
]);
return ['success' => false, 'error' => $error];
}
$data = $res->json();
$response = $data['choices'][0]['message']['content'];
$usage = $data['usage'] ?? [];
$result = [
'success' => true,
'response' => $response,
'usage' => $usage,
'model' => $model,
'latency' => microtime(true) - $startTime,
];
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'response' => $response,
'usage' => $usage,
'status' => 'success',
'metadata' => ['latency' => $result['latency']],
]);
return $result;
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
protected function calculateCost(array $data): float
{
$model = $data['model'] ?? '';
$promptTokens = $data['usage']['prompt_tokens'] ?? 0;
$completionTokens = $data['usage']['completion_tokens'] ?? 0;
// Simplified estimation
$rates = [
'gpt-4o' => ['prompt' => 0.005 / 1000, 'completion' => 0.015 / 1000],
'gpt-4-turbo' => ['prompt' => 0.01 / 1000, 'completion' => 0.03 / 1000],
'gpt-3.5-turbo' => ['prompt' => 0.0005 / 1000, 'completion' => 0.0015 / 1000],
];
$rate = $rates[$model] ?? $rates['gpt-4o'];
return ($promptTokens * $rate['prompt']) + ($completionTokens * $rate['completion']);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*/
namespace App\Services\AI;
use Illuminate\Support\Facades\Cache;
use Spatie\Activitylog\Models\Activity;
class LogAnalysisService
{
public function __construct(
protected AiService $aiService
) {}
/**
* Analyze recent activity logs using AI.
*/
public function analyzeRecentLogs(int $limit = 50): string
{
if (! get_setting('ai_enabled', false)) {
return 'AI Service is currently disabled in system settings.';
}
$logs = Activity::with('causer')
->latest()
->take($limit)
->get()
->map(function ($log) {
return [
'time' => $log->created_at->toDateTimeString(),
'user' => $log->causer ? $log->causer->name : 'System',
'action' => $log->description,
'subject' => $log->subject_type ? class_basename($log->subject_type) : 'N/A',
];
})
->toArray();
if (empty($logs)) {
return 'No activity logs found to analyze.';
}
$prompt = 'As a Security and Operational Auditor, analyze the following system activity logs and provide:
1. A brief summary of recent operations.
2. Security insights (detect any suspicious patterns or potential privilege abuse).
3. Operational health status.
4. Recommendations (if any).
FORMAT: Use Markdown with bold headers. Be concise and professional.
LOGS DATA:
'.json_encode($logs, JSON_PRETTY_PRINT);
try {
return Cache::remember('ai_log_analysis_result', 3600, function () use ($prompt) {
$result = $this->aiService->provider()->generate($prompt);
if (isset($result['success']) && $result['success']) {
return $result['response'] ?? 'AI failed to generate analysis.';
}
return 'AI Provider Error: '.($result['error'] ?? 'Unknown error');
});
} catch (\Exception $e) {
return 'Error during AI analysis: '.$e->getMessage();
}
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
class OllamaProvider extends BaseAiProvider
{
public function getIdentifier(): string
{
return 'ollama';
}
public function generate(string $prompt, array $options = []): array
{
$baseUrl = $options['base_url'] ?? $this->config['base_url'] ?? 'http://localhost:11434';
$model = $options['model'] ?? $this->config['default_model'] ?? 'llama3';
$instruction = $options['instruction'] ?? $this->config['instruction'] ?? '';
$startTime = microtime(true);
try {
$res = Http::timeout(120)->post("{$baseUrl}/api/generate", [
'model' => $model,
'prompt' => $instruction."\n\n".$prompt,
'stream' => false,
'options' => [
'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7),
'num_predict' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000),
],
]);
if ($res->failed()) {
$error = 'Ollama Error: Make sure the server is running and the model is pulled.';
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'status' => 'failed',
'error' => $error,
]);
return ['success' => false, 'error' => $error];
}
$data = $res->json();
$response = $data['response'];
$usage = [
'prompt_tokens' => $data['prompt_eval_count'] ?? 0,
'completion_tokens' => $data['eval_count'] ?? 0,
'total_tokens' => ($data['prompt_eval_count'] ?? 0) + ($data['eval_count'] ?? 0),
];
$result = [
'success' => true,
'response' => $response,
'usage' => $usage,
'model' => $model,
'latency' => microtime(true) - $startTime,
];
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'response' => $response,
'usage' => $usage,
'status' => 'success',
'metadata' => ['latency' => $result['latency']],
]);
return $result;
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Services\AI;
use Illuminate\Support\Facades\Http;
class OpenAiCompatibleProvider extends BaseAiProvider
{
protected $identifier;
protected $baseUrl;
public function __construct(string $identifier, string $baseUrl, array $config = [])
{
parent::__construct($config);
$this->identifier = $identifier;
$this->baseUrl = $baseUrl;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function generate(string $prompt, array $options = []): array
{
$key = $options['key'] ?? $this->config['key'] ?? null;
if (! $key) {
return ['success' => false, 'error' => ucfirst($this->identifier).' API Key not configured.'];
}
$model = $options['model'] ?? $this->config['default_model'] ?? null;
$instruction = $options['instruction'] ?? $this->config['instruction'] ?? '';
$startTime = microtime(true);
try {
$res = Http::timeout(60)->withToken($key)
->post($this->baseUrl, [
'model' => $model,
'messages' => [
['role' => 'system', 'content' => $instruction],
['role' => 'user', 'content' => $prompt],
],
'temperature' => (float) ($options['temperature'] ?? $this->config['temperature'] ?? 0.7),
'max_tokens' => (int) ($options['max_tokens'] ?? $this->config['max_tokens'] ?? 2000),
]);
if ($res->failed()) {
$error = $res->json()['error']['message'] ?? $res->json()['message'] ?? ucfirst($this->identifier).' Error';
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'status' => 'failed',
'error' => $error,
]);
return ['success' => false, 'error' => $error];
}
$data = $res->json();
$response = $data['choices'][0]['message']['content'];
$usage = $data['usage'] ?? [
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
];
$result = [
'success' => true,
'response' => $response,
'usage' => $usage,
'model' => $model,
'latency' => microtime(true) - $startTime,
];
$this->logUsage([
'model' => $model,
'prompt' => $prompt,
'response' => $response,
'usage' => $usage,
'status' => 'success',
'metadata' => ['latency' => $result['latency']],
]);
return $result;
} catch (\Exception $e) {
return ['success' => false, 'error' => $e->getMessage()];
}
}
}
@@ -0,0 +1,93 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*/
namespace App\Services\AI;
use App\Services\SystemConfig\SystemConfigService;
use Illuminate\Support\Facades\Cache;
class SecurityHardeningService
{
public function __construct(
protected AiService $aiService,
protected SystemConfigService $configService
) {}
/**
* Audit system security settings and get AI recommendations.
*/
public function auditSecurity(): array
{
if (! get_setting('ai_enabled', false)) {
return ['error' => 'AI Service disabled.'];
}
// Collect relevant security settings
$settings = [
'force_https' => get_setting('force_https'),
'hsts_enabled' => get_setting('hsts_enabled'),
'two_factor_auth' => get_setting('two_factor_auth'),
'password_min_length' => get_setting('password_min_length'),
'login_max_attempts' => get_setting('login_max_attempts'),
'session_lifetime' => get_setting('session_lifetime'),
'ip_whitelist_admin' => ! empty(get_setting('ip_whitelist_admin')),
'backup_db_encrypt' => get_setting('backup_db_encrypt'),
'maintenance_mode' => get_setting('maintenance_mode_enabled'),
'environment' => app()->environment(),
'debug_mode' => config('app.debug'),
];
$prompt = 'As a Cyber Security Expert, audit the following Laravel system security configurations and provide:
1. A Security Score (0-100).
2. Critical Vulnerabilities (if any).
3. Hardening Recommendations.
4. A JSON object summary at the end.
CONFIGURATIONS:
'.json_encode($settings, JSON_PRETTY_PRINT);
try {
return Cache::remember('security_audit_result', 86400, function () use ($prompt) {
$result = $this->aiService->provider()->generate($prompt);
if (isset($result['success']) && $result['success']) {
return [
'analysis' => $result['response'],
'score' => $this->extractScore($result['response']),
'timestamp' => now()->toDateTimeString(),
];
}
return ['error' => $result['error'] ?? 'Unknown error'];
});
} catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
}
private function extractScore(string $text): int
{
preg_match('/Score:?\s*(\d+)/i', $text, $matches);
return isset($matches[1]) ? (int) $matches[1] : 70;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Auth;
use App\Models\OtpCode;
use Carbon\Carbon;
class OtpService
{
/**
* Generate and save an OTP code for an identifier.
*/
public function generate(string $identifier, int $expiryMinutes = 10): string
{
// Delete old unexpired codes for this identifier to avoid clutter
OtpCode::where('identifier', $identifier)
->whereNull('verified_at')
->delete();
$code = (string) random_int(100000, 999999);
OtpCode::create([
'identifier' => $identifier,
'code' => $code,
'expires_at' => Carbon::now()->addMinutes($expiryMinutes),
]);
return $code;
}
/**
* Verify the OTP code.
*/
public function verify(string $identifier, string $code): bool
{
$otp = OtpCode::where('identifier', $identifier)
->where('code', $code)
->whereNull('verified_at')
->where('expires_at', '>', Carbon::now())
->latest()
->first();
if ($otp) {
$otp->update(['verified_at' => Carbon::now()]);
return true;
}
return false;
}
/**
* Clear expired codes.
*/
public function cleanup(): void
{
OtpCode::where('expires_at', '<', Carbon::now())
->whereNull('verified_at')
->delete();
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services\Auth;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
class PasswordPolicyService
{
/**
* Get the password validation rules based on system settings.
*/
public static function getRules(): Password
{
$rules = Password::min(get_setting('password_min_length', 8))
->max(get_setting('password_max_length', 64));
$requireUpper = get_setting('password_require_uppercase', false);
$requireLower = get_setting('password_require_lowercase', false);
if ($requireUpper && $requireLower) {
$rules->mixedCase();
} elseif ($requireUpper) {
$rules->rules(['regex:/[A-Z]/']);
} elseif ($requireLower) {
$rules->rules(['regex:/[a-z]/']);
}
if (get_setting('password_require_numeric', false)) {
$rules->numbers();
}
if (get_setting('password_require_special', false)) {
$rules->symbols();
}
return $rules;
}
/**
* Check if the password has expired for a user.
*/
public static function isPasswordExpired(User $user): bool
{
$expiryDays = get_setting('password_expiry_days', 0);
if ($expiryDays <= 0) {
return false;
}
$lastChanged = $user->password_changed_at ?? $user->created_at;
return $lastChanged->addDays($expiryDays)->isPast();
}
/**
* Verify that the new password is not in the user's password history.
*/
public static function checkHistory(User $user, string $newPassword): void
{
$historyCount = get_setting('password_history_count', 0);
if ($historyCount <= 0) {
return;
}
$histories = $user->passwordHistories()
->latest()
->take($historyCount)
->get();
foreach ($histories as $history) {
if (Hash::check($newPassword, $history->password)) {
throw ValidationException::withMessages([
'password' => __('You cannot reuse any of your last :count passwords.', ['count' => $historyCount]),
]);
}
}
}
/**
* Record the current password into history and update changed_at timestamp.
*/
public static function recordPasswordChange(User $user, string $newPasswordHash): void
{
$historyCount = get_setting('password_history_count', 0);
// 1. Record to history (only if enabled)
if ($historyCount > 0) {
$user->passwordHistories()->create([
'password' => $newPasswordHash,
]);
}
// 2. Update timestamp
$user->update([
'password_changed_at' => now(),
]);
// 3. Optional: Prune old history — keep exactly $historyCount entries
$historyCount = get_setting('password_history_count', 0);
if ($historyCount > 0) {
$user->passwordHistories()
->orderBy('created_at', 'desc')
->skip($historyCount)
->take(100)
->delete();
}
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Services;
use App\Models\DeviceToken;
use App\Services\MobileConfig\MobileConfigService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class FcmService
{
private string $serverKey;
private string $endpoint = 'https://fcm.googleapis.com/fcm/send';
protected $mobileConfig;
public function __construct(MobileConfigService $mobileConfig)
{
$this->serverKey = config('services.fcm.server_key', '');
$this->mobileConfig = $mobileConfig;
}
/**
* Check if notifications are enabled globally in settings.
*/
private function isEnabled(): bool
{
$config = $this->mobileConfig->all();
return filter_var($config['notifications']['enable_push_notifications'] ?? true, FILTER_VALIDATE_BOOLEAN);
}
/**
* Send push notification to a single user (all their devices).
*/
public function sendToUser(int $userId, string $title, string $body, array $data = []): void
{
if (! $this->isEnabled()) {
return;
}
$tokens = DeviceToken::where('user_id', $userId)->pluck('token')->toArray();
if (empty($tokens)) {
return;
}
$this->sendToTokens($tokens, $title, $body, $data);
}
/**
* Send to a list of device tokens.
*/
public function sendToTokens(array $tokens, string $title, string $body, array $data = []): void
{
if (! $this->isEnabled()) {
return;
}
if (empty($this->serverKey)) {
Log::warning('FCM server key not configured — push notification skipped');
return;
}
// FCM supports max 1000 tokens per request
foreach (array_chunk($tokens, 1000) as $chunk) {
$this->dispatch($chunk, $title, $body, $data);
}
}
private function dispatch(array $tokens, string $title, string $body, array $data): void
{
$payload = [
'registration_ids' => $tokens,
'notification' => ['title' => $title, 'body' => $body],
'data' => $data,
'priority' => 'high',
];
try {
$response = Http::withHeaders([
'Authorization' => 'key='.$this->serverKey,
'Content-Type' => 'application/json',
])->post($this->endpoint, $payload);
if (! $response->successful()) {
Log::error('FCM dispatch failed', ['status' => $response->status(), 'body' => $response->body()]);
} else {
$this->handleFcmResponse($response->json(), $tokens);
}
} catch (\Throwable $e) {
Log::error('FCM HTTP error', ['error' => $e->getMessage()]);
}
}
private function handleFcmResponse(array $result, array $tokens): void
{
if (empty($result['results'])) {
return;
}
$invalidTokens = [];
foreach ($result['results'] as $index => $res) {
if (isset($res['error']) && in_array($res['error'], ['NotRegistered', 'InvalidRegistration'])) {
$invalidTokens[] = $tokens[$index];
}
}
if (! empty($invalidTokens)) {
DeviceToken::whereIn('token', $invalidTokens)->delete();
Log::info('FCM removed stale tokens', ['count' => count($invalidTokens)]);
}
}
}
@@ -0,0 +1,394 @@
<?php
namespace App\Services\MobileConfig;
use App\Models\MobileSetting;
use App\Models\SystemSetting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MobileConfigService
{
private const CACHE_KEY = 'mobile_config_all';
private const DEFINITIONS = [
// 1. BRANDING
'app_name' => ['type' => 'string', 'group' => 'branding', 'default' => 'biiproject'],
'app_tagline' => ['type' => 'string', 'group' => 'branding', 'default' => 'Smart Solution for Your Business'],
'app_icon_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null],
'logo_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null],
'splash_image_url' => ['type' => 'image_path', 'group' => 'branding', 'default' => null],
'brand_color' => ['type' => 'string', 'group' => 'branding', 'default' => '#C6F135'],
'theme_color_primary' => ['type' => 'string', 'group' => 'branding', 'default' => '#C6F135'],
'theme_color_secondary' => ['type' => 'string', 'group' => 'branding', 'default' => '#1A1A1A'],
'primary_font_family' => ['type' => 'string', 'group' => 'branding', 'default' => 'Outfit'],
// 2. CONTROL CENTER
'kill_switch_active' => ['type' => 'boolean', 'group' => 'control_center', 'default' => false],
'kill_switch_message' => ['type' => 'string', 'group' => 'control_center', 'default' => 'System is currently undergoing emergency maintenance. Please try again later.'],
'maintenance_start_at' => ['type' => 'string', 'group' => 'control_center', 'default' => null],
'maintenance_end_at' => ['type' => 'string', 'group' => 'control_center', 'default' => null],
'maintenance_bypass_ips' => ['type' => 'string', 'group' => 'control_center', 'default' => '127.0.0.1'],
'announcement_enabled' => ['type' => 'boolean', 'group' => 'control_center', 'default' => false],
'announcement_text' => ['type' => 'string', 'group' => 'control_center', 'default' => 'Maintenance is scheduled for tonight at 12:00 AM.'],
'announcement_type' => ['type' => 'string', 'group' => 'control_center', 'default' => 'info'],
// 3. APP UPDATES
'app_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '2.0.0'],
'min_app_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '1.0.0'],
'onboarding_version' => ['type' => 'string', 'group' => 'app_updates', 'default' => '1.0.0'],
'store_url_android' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://play.google.com/store/apps/details?id=com.biiproject'],
'store_url_ios' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://apps.apple.com/app/biiproject'],
'store_url_huawei' => ['type' => 'string', 'group' => 'app_updates', 'default' => 'https://appgallery.huawei.com/'],
// 4. FEATURES
'enable_registration' => ['type' => 'boolean', 'group' => 'features', 'default' => true],
'enable_guest_mode' => ['type' => 'boolean', 'group' => 'features', 'default' => false],
'require_otp_registration' => ['type' => 'boolean', 'group' => 'features', 'default' => false],
'enable_biometrics' => ['type' => 'boolean', 'group' => 'features', 'default' => false],
'enable_remember_me' => ['type' => 'boolean', 'group' => 'features', 'default' => true],
'review_prompt_enabled' => ['type' => 'boolean', 'group' => 'features', 'default' => true],
'min_actions_before_review' => ['type' => 'integer', 'group' => 'features', 'default' => 10],
'region_lock_enabled' => ['type' => 'boolean', 'group' => 'features', 'default' => false],
// 5. SECURITY & AUTH
'login_title' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'biiproject'],
'login_subtitle' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'Sign in to continue'],
'token_ttl_minutes' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 43200],
'session_max_age' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 86400],
'login_max_attempts' => ['type' => 'integer', 'group' => 'security_auth', 'default' => 5],
'biometric_auth_type' => ['type' => 'string', 'group' => 'security_auth', 'default' => 'any'],
'oauth_google_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false],
'oauth_apple_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false],
'oauth_facebook_enabled' => ['type' => 'boolean', 'group' => 'security_auth', 'default' => false],
// 6. CONNECTIVITY
'api_base_url' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'https://api.biiproject.com'],
'api_version' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'v1'],
'api_timeout_ms' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 30000],
'api_retry_count' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 3],
'request_cache_ttl' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 3600],
'sync_interval_ms' => ['type' => 'integer', 'group' => 'connectivity', 'default' => 10000],
'enable_ssl_pinning' => ['type' => 'boolean', 'group' => 'connectivity', 'default' => false],
'ssl_pinning_hash' => ['type' => 'string', 'group' => 'connectivity', 'default' => null],
'environment_selector' => ['type' => 'string', 'group' => 'connectivity', 'default' => 'production'],
// 7. NOTIFICATIONS
'enable_push_notifications' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true],
'fcm_topic_default' => ['type' => 'string', 'group' => 'notifications', 'default' => 'all_users'],
'default_channel_id' => ['type' => 'string', 'group' => 'notifications', 'default' => 'default_channel'],
'notification_sound_enabled' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true],
'badge_count_enabled' => ['type' => 'boolean', 'group' => 'notifications', 'default' => true],
'priority_level' => ['type' => 'string', 'group' => 'notifications', 'default' => 'high'],
// 8. SUPPORT & SOCIAL
'support_email' => ['type' => 'string', 'group' => 'support_social', 'default' => 'support@biiproject.com'],
'support_whatsapp' => ['type' => 'string', 'group' => 'support_social', 'default' => '628123456789'],
'live_chat_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
'faq_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
'privacy_policy_url' => ['type' => 'string', 'group' => 'support_social', 'default' => 'https://biiproject.com/privacy'],
'social_instagram_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
'social_twitter_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
'social_facebook_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
'social_youtube_url' => ['type' => 'string', 'group' => 'support_social', 'default' => null],
// 9. ANALYTICS & SYSTEM
'crashlytics_enabled' => ['type' => 'boolean', 'group' => 'analytics_system', 'default' => true],
'log_level' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'error'],
'event_sampling_rate' => ['type' => 'string', 'group' => 'analytics_system', 'default' => '1.0'],
'google_analytics_id' => ['type' => 'string', 'group' => 'analytics_system', 'default' => null],
'gdpr_compliance_enabled' => ['type' => 'boolean', 'group' => 'analytics_system', 'default' => false],
'target_sdk_version' => ['type' => 'string', 'group' => 'analytics_system', 'default' => '34'],
'system_timezone' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'Asia/Jakarta'],
'default_locale' => ['type' => 'string', 'group' => 'analytics_system', 'default' => 'en'],
// 10. DYNAMIC CONTENT
'dashboard_categories' => ['type' => 'string', 'group' => 'features', 'default' => 'All,Tech,Finance,Health,Coding'],
'faq_json' => ['type' => 'json', 'group' => 'support_social', 'default' => '[{"q":"How to sync?","a":"Click the sync button on dashboard."}]'],
'help_topics_json' => ['type' => 'json', 'group' => 'support_social', 'default' => '[{"id":"1","name":"Account","icon":"user"},{"id":"2","name":"System","icon":"cpu"}]'],
];
public static function getDefinitions(): array
{
return self::DEFINITIONS;
}
public function all(bool $refresh = false): array
{
if ($refresh) {
$this->clearCache();
}
return Cache::remember(self::CACHE_KEY, now()->addHours(24), function () {
$settings = MobileSetting::all();
$config = [];
// 1. Initialize from definitions
foreach (self::DEFINITIONS as $key => $def) {
if (! isset($config[$def['group']])) {
$config[$def['group']] = [];
}
$config[$def['group']][$key] = $def['default'];
}
// 2. Fallback from Global System Settings (Branding)
// This ensures if mobile settings are empty, we use the main site branding
if (class_exists(SystemSetting::class)) {
$systemSettings = SystemSetting::whereIn('key', ['app_name', 'app_logo', 'app_tagline', 'app_tagline1', 'app_tagline2'])
->pluck('value', 'key');
if ($systemSettings->has('app_name')) {
$config['branding']['app_name'] = $systemSettings->get('app_name');
$config['security_auth']['login_title'] = $systemSettings->get('app_name');
}
if ($systemSettings->has('app_logo')) {
$logoPath = $systemSettings->get('app_logo');
$logoUrl = Str::startsWith($logoPath, 'http') ? $logoPath : asset($logoPath);
$config['branding']['logo_url'] = $logoUrl;
}
if ($systemSettings->has('app_tagline')) {
$config['security_auth']['login_subtitle'] = strip_tags($systemSettings->get('app_tagline'));
$config['branding']['app_tagline'] = strip_tags($systemSettings->get('app_tagline'));
}
}
// 3. Overlay with Mobile-Specific settings (Overwrites fallbacks)
foreach ($settings as $setting) {
$value = $this->castValue($setting->value, $setting->type);
// Handle Assets (Storage URLs)
if ($setting->type === 'image_path' && $value && ! Str::startsWith($value, 'http')) {
$value = asset($value);
}
$group = $setting->group;
// Skip if value is default/null to keep system fallback
if (($setting->key === 'logo_url' || $setting->key === 'app_name') && (empty($value) || $value === 'biiproject')) {
continue;
}
// Handle Localization Dynamic Keys
if ($group === 'localization') {
if (! isset($config['localization'])) {
$config['localization'] = ['English' => [], 'Indonesian' => []];
}
if (str_starts_with($setting->key, 'lang_en_')) {
$key = str_replace('lang_en_', '', $setting->key);
$config['localization']['English'][$key] = $value;
} elseif (str_starts_with($setting->key, 'lang_id_')) {
$key = str_replace('lang_id_', '', $setting->key);
$config['localization']['Indonesian'][$key] = $value;
}
continue;
}
if (! isset($config[$group])) {
$config[$group] = [];
}
// Clean HTML tags for mobile display on critical text fields
if (in_array($setting->key, ['kill_switch_message', 'announcement_text'])) {
$value = trim(strip_tags((string) $value));
}
$config[$group][$setting->key] = $value;
}
return $config;
});
}
public function getGroupedSettingsForAdmin(): array
{
$settings = MobileSetting::all()->keyBy('key');
$grouped = [];
// 1. Process from Definitions (Ensure defaults)
foreach (self::DEFINITIONS as $key => $def) {
$setting = $settings->get($key);
if (! $setting) {
$setting = new MobileSetting([
'key' => $key,
'group' => $def['group'],
'type' => $def['type'],
'value' => is_bool($def['default']) ? ($def['default'] ? 'true' : 'false') : (string) $def['default'],
]);
}
if (! isset($grouped[$def['group']])) {
$grouped[$def['group']] = collect();
}
$grouped[$def['group']]->push($setting);
$settings->forget($key); // Mark as processed
}
// 2. Process remaining settings in DB (like dynamic localization keys)
foreach ($settings as $setting) {
if (! isset($grouped[$setting->group])) {
$grouped[$setting->group] = collect();
}
$grouped[$setting->group]->push($setting);
}
return $grouped;
}
/**
* Get all keys that should be treated as boolean.
*/
public function getBooleanKeys(): array
{
$definitions = collect(self::DEFINITIONS)
->filter(fn ($def) => $def['type'] === 'boolean')
->keys()
->toArray();
$db = MobileSetting::where('type', 'boolean')->pluck('key')->toArray();
return array_unique(array_merge($definitions, $db));
}
public function update(array $data, array $files = []): void
{
\DB::transaction(function () use ($data, $files) {
$settings = MobileSetting::all()->keyBy('key');
// Handle Files
foreach ($files as $key => $file) {
if ($file instanceof UploadedFile) {
$setting = $settings->get($key);
$def = self::DEFINITIONS[$key] ?? null;
if (! $setting && $def) {
$setting = MobileSetting::create([
'key' => $key,
'group' => $def['group'],
'type' => $def['type'],
]);
$settings->put($key, $setting); // Sync to collection
}
if ($setting && $setting->type === 'image_path') {
// Delete old file if exists
if ($setting->value) {
$oldPath = str_replace('/storage/', '', $setting->value);
Storage::disk('public')->delete($oldPath);
}
// OPTIMIZATION: Convert to WebP (only if GD extension is available)
$hasGd = function_exists('imagewebp') && function_exists('imagecreatefromstring');
$filename = $key.'_'.time().($hasGd ? '.webp' : '.'.$file->getClientOriginalExtension());
if ($hasGd) {
$tempPath = $file->getRealPath();
$content = file_get_contents($tempPath);
$image = imagecreatefromstring($content);
if ($image) {
imagepalettetotruecolor($image);
imagealphablending($image, true);
imagesavealpha($image, true);
// NEW: Auto Resize (Max width 1200px)
$width = imagesx($image);
$height = imagesy($image);
if ($width > 1200) {
$newWidth = 1200;
$newHeight = (int) ($height * (1200 / $width));
$resized = imagecreatetruecolor($newWidth, $newHeight);
imagealphablending($resized, false);
imagesavealpha($resized, true);
imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
imagedestroy($image);
$image = $resized;
}
ob_start();
imagewebp($image, null, 80);
$webpContent = ob_get_clean();
imagedestroy($image);
Storage::disk('public')->put('mobile-assets/'.$filename, $webpContent);
$data[$key] = '/storage/mobile-assets/'.$filename;
} else {
$hasGd = false;
}
}
if (! $hasGd) {
// Standard store fallback
$path = $file->storeAs('mobile-assets', $filename, 'public');
$data[$key] = '/storage/'.$path;
}
}
}
}
// Handle Values
foreach ($data as $key => $value) {
$setting = $settings->get($key);
$def = self::DEFINITIONS[$key] ?? null;
// Handle dynamic keys (like localization) that are already in DB but not in DEFINITIONS
if (! $def && ! $setting) {
continue;
}
if (! $setting && $def) {
$setting = MobileSetting::create([
'key' => $key,
'group' => $def['group'],
'type' => $def['type'],
]);
}
$newValue = $value;
if ($setting->type === 'boolean') {
$newValue = (filter_var($value, FILTER_VALIDATE_BOOLEAN) || $value === '1' || $value === 'true') ? 'true' : 'false';
}
$setting->update(['value' => $newValue]);
}
});
$this->clearCache();
}
/**
* Clear the cache.
*/
public function clearCache(): void
{
self::clearCacheStatic();
}
public static function clearCacheStatic(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* Cast raw string value from DB to appropriate PHP type.
*/
private function castValue(mixed $value, string $type): mixed
{
if ($value === null) {
return null;
}
return match ($type) {
'boolean' => $value === 'true' || $value === '1' || $value === true,
'int', 'integer' => (int) $value,
'json' => json_decode($value, true),
default => $value,
};
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Services\Monitoring;
class MonitoringFormatter
{
public static function bytes(int|float $bytes, int $precision = 1): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = (int) floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision).' '.$units[$pow];
}
/**
* Parse a human-readable byte string (e.g. "1.5 KB", "2 GB") back into a float byte count.
* Falls back to numeric cast for plain numbers and returns the leading number when the
* unit suffix is unknown.
*/
public static function parseBytes(string $value): float
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$parts = explode(' ', trim($value));
if (count($parts) < 2) {
return (float) $value;
}
$num = (float) $parts[0];
$idx = array_search(strtoupper($parts[1]), $units, true);
if ($idx === false) {
return $num;
}
return $num * (1024 ** $idx);
}
public static function duration(int|float $seconds): string
{
$days = (int) floor($seconds / 86400);
$hours = (int) floor(($seconds % 86400) / 3600);
$minutes = (int) floor(($seconds % 3600) / 60);
$parts = [];
if ($days > 0) {
$parts[] = "{$days}d";
}
if ($hours > 0) {
$parts[] = "{$hours}h";
}
if ($minutes > 0) {
$parts[] = "{$minutes}m";
}
return count($parts) > 0 ? implode(' ', $parts) : '< 1m';
}
}
@@ -0,0 +1,710 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\Monitoring;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Storage;
class SystemMonitoringService
{
// ========================
// CPU Usage
// ========================
public function getCpuUsage()
{
if (PHP_OS_FAMILY === 'Windows') {
$output = shell_exec('wmic cpu get loadpercentage /value');
if (! $output) {
return 0;
}
preg_match('/LoadPercentage=(\d+)/', $output, $matches);
return isset($matches[1]) ? (int) $matches[1] : 0;
}
// Linux / Unix
$load = sys_getloadavg()[0] ?? 0;
$cores = (int) shell_exec('nproc') ?: 1;
return (int) round(($load / $cores) * 100);
}
// ========================
// RAM Usage
// ========================
public function getRamUsage()
{
return $this->getFormattedRamUsage()['percentage'];
}
public function getFormattedRamUsage()
{
if (PHP_OS_FAMILY === 'Windows') {
$output = shell_exec('wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value');
if ($output) {
preg_match('/TotalVisibleMemorySize=(\d+)/', $output, $total);
preg_match('/FreePhysicalMemory=(\d+)/', $output, $free);
if (isset($total[1], $free[1])) {
$totalBytes = (float) $total[1] * 1024;
$freeBytes = (float) $free[1] * 1024;
$usedBytes = $totalBytes - $freeBytes;
return [
'total' => MonitoringFormatter::bytes($totalBytes),
'free' => MonitoringFormatter::bytes($freeBytes),
'used' => MonitoringFormatter::bytes($usedBytes),
'percentage' => (int) round(($usedBytes / $totalBytes) * 100),
];
}
}
} else {
$info = @file_get_contents('/proc/meminfo');
if ($info) {
preg_match('/MemTotal:\s+(\d+)/', $info, $total);
preg_match('/MemAvailable:\s+(\d+)/', $info, $available);
preg_match('/SwapTotal:\s+(\d+)/', $info, $swapTotal);
preg_match('/SwapFree:\s+(\d+)/', $info, $swapFree);
if (isset($total[1], $available[1])) {
$totalBytes = (float) $total[1] * 1024;
$availableBytes = (float) $available[1] * 1024;
$usedBytes = $totalBytes - $availableBytes;
$swapTotalBytes = isset($swapTotal[1]) ? (float) $swapTotal[1] * 1024 : 0;
$swapFreeBytes = isset($swapFree[1]) ? (float) $swapFree[1] * 1024 : 0;
$swapUsedBytes = $swapTotalBytes - $swapFreeBytes;
return [
'total' => MonitoringFormatter::bytes($totalBytes),
'free' => MonitoringFormatter::bytes($availableBytes),
'used' => MonitoringFormatter::bytes($usedBytes),
'percentage' => (int) round(($usedBytes / $totalBytes) * 100),
'swap' => [
'total' => MonitoringFormatter::bytes($swapTotalBytes),
'used' => MonitoringFormatter::bytes($swapUsedBytes),
'percentage' => $swapTotalBytes > 0 ? (int) round(($swapUsedBytes / $swapTotalBytes) * 100) : 0,
],
];
}
}
}
return [
'total' => 'Unknown',
'free' => 'Unknown',
'used' => 'Unknown',
'percentage' => 0,
];
}
// ========================
// Disk Usage
// ========================
public function getDiskUsage()
{
return $this->getFormattedDiskUsage()['percentage'];
}
public function getFormattedDiskUsage()
{
$path = PHP_OS_FAMILY === 'Windows' ? 'C:' : '/';
$total = @disk_total_space($path);
$free = @disk_free_space($path);
if (! $total) {
return [
'total' => 'Unknown',
'free' => 'Unknown',
'used' => 'Unknown',
'percentage' => 0,
];
}
$used = $total - $free;
return [
'total' => MonitoringFormatter::bytes($total),
'free' => MonitoringFormatter::bytes($free),
'used' => MonitoringFormatter::bytes($used),
'percentage' => (int) round(($used / $total) * 100),
];
}
// ========================
// Active Users (Laravel Session)
// ========================
public function getActiveUsers()
{
return Cache::remember('monitoring_active_users', 60, function () {
$driver = config('session.driver');
$data = ['total' => 0, 'authenticated' => 0];
try {
if ($driver === 'database') {
if (! DB::getSchemaBuilder()->hasTable('sessions')) {
return $data;
}
$data['total'] = DB::table('sessions')->count();
$data['authenticated'] = DB::table('sessions')->whereNotNull('user_id')->count();
return $data;
}
if ($driver === 'redis') {
$connection = config('session.connection') ?? 'default';
$redis = Redis::connection($connection);
$sessionCookie = config('session.cookie', 'laravel_session');
$patterns = [
$sessionCookie.':*',
str_replace('-', '_', $sessionCookie).':*',
str_replace('_', '-', $sessionCookie).':*',
'*_session:*',
];
$count = 0;
foreach ($patterns as $pattern) {
$cursor = '0';
do {
$result = $redis->scan($cursor, ['match' => $pattern, 'count' => 100]);
$cursor = $result[0];
$count += count($result[1]);
} while ($cursor !== '0');
}
$data['total'] = $count;
$data['authenticated'] = 'N/A'; // Redis scan doesn't expose user_id easily
return $data;
}
if ($driver === 'file') {
$path = config('session.files');
if (! file_exists($path)) {
return $data;
}
$data['total'] = count(glob($path.'/*'));
$data['authenticated'] = 'N/A';
return $data;
}
} catch (\Exception $e) {
return $data;
}
return $data;
});
}
public function getRegisteredUsers()
{
try {
return User::count();
} catch (\Exception $e) {
return 0;
}
}
// ========================
// System Info
// ========================
public function getPhpVersion()
{
return PHP_VERSION;
}
public function getDatabaseVersion()
{
try {
return DB::connection()->getPdo()->getAttribute(\PDO::ATTR_SERVER_VERSION);
} catch (\Exception $e) {
return 'Unknown';
}
}
public function getServerIp()
{
return request()->server('SERVER_ADDR') ?? gethostbyname(gethostname());
}
public function getUptime()
{
if (PHP_OS_FAMILY === 'Windows') {
$output = shell_exec('wmic os get lastbootuptime /value');
if ($output && preg_match('/LastBootUpTime=(\d+)/', $output, $matches)) {
$bootTime = $matches[1]; // Format: YYYYMMDDHHMMSS.MMMMMM+UUU
$year = substr($bootTime, 0, 4);
$month = substr($bootTime, 4, 2);
$day = substr($bootTime, 6, 2);
$hour = substr($bootTime, 8, 2);
$minute = substr($bootTime, 10, 2);
$second = substr($bootTime, 12, 2);
$bootTimestamp = strtotime("$year-$month-$day $hour:$minute:$second");
$diff = time() - $bootTimestamp;
return MonitoringFormatter::duration($diff);
}
} else {
$uptime = @file_get_contents('/proc/uptime');
if ($uptime) {
$uptime = explode(' ', $uptime)[0];
return MonitoringFormatter::duration((int) $uptime);
}
}
return 'Unknown';
}
// ========================
// Queue Stats
// ========================
public function getQueueStats()
{
try {
// Use Cache Heartbeat (updated by WorkerHeartbeatJob)
$lastHeartbeat = Cache::get('queue_worker_heartbeat');
$workerRunning = $lastHeartbeat && (now()->timestamp - $lastHeartbeat) < 300; // 5 minute threshold
// Fallback to PS if heartbeat is missing (optional, but keep for robustness)
if (! $workerRunning && PHP_OS_FAMILY !== 'Windows') {
$output = @shell_exec('ps aux | grep "queue:work" | grep -v grep');
$workerRunning = ! empty($output);
}
// Throughput Estimation (Activities in last 5 minutes)
$throughput = 0;
if (DB::getSchemaBuilder()->hasTable('activity_log')) {
$throughput = DB::table('activity_log')
->where('created_at', '>=', now()->subMinutes(5))
->count();
}
return [
'pending' => DB::table('jobs')->count(),
'failed' => DB::table('failed_jobs')->count(),
'worker_active' => (bool) $workerRunning,
'throughput' => $throughput,
'load_factor' => $throughput > 0 ? round($throughput / 5, 1) : 0, // tasks per minute
];
} catch (\Exception $e) {
return [
'pending' => 0,
'failed' => 0,
'worker_active' => false,
];
}
}
// ========================
// Database Info
// ========================
public function getDatabaseInfo()
{
return Cache::remember('monitoring_database_info', 300, function () {
try {
$driver = DB::getDriverName();
$tables = 0;
$size = 0;
$topTables = [];
// Performance Check
$start = microtime(true);
DB::select('SELECT 1');
$latency = (int) round((microtime(true) - $start) * 1000);
if ($driver === 'pgsql') {
$tableCount = DB::select("SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'");
$tables = $tableCount[0]->count ?? 0;
$dbSize = DB::select('SELECT pg_database_size(current_database()) AS size');
$size = $dbSize[0]->size ?? 0;
// Technical detail: Top 5 tables by size
$topTables = DB::select('
SELECT relname AS table,
pg_size_pretty(pg_total_relation_size(relid)) AS size_pretty,
pg_total_relation_size(relid) AS size_bytes
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 5
');
} else {
$tableCount = DB::select('SHOW TABLES');
$tables = count($tableCount);
$dbSize = DB::select('SELECT SUM(data_length + index_length) AS size FROM information_schema.TABLES WHERE table_schema = DATABASE()');
$size = $dbSize[0]->size ?? 0;
}
return [
'tables' => $tables,
'size' => MonitoringFormatter::bytes($size),
'size_bytes' => $size,
'top_tables' => $topTables,
'latency' => $latency.'ms',
'status' => $latency < 100 ? 'STABLE' : 'DEGRADED',
];
} catch (\Exception $e) {
return [
'tables' => 0,
'size' => 'Unknown',
'size_bytes' => 0,
'top_tables' => [],
'latency' => '0ms',
'status' => 'OFFLINE',
];
}
});
}
// ========================
// Redis Info
// ========================
public function getRedisStats()
{
try {
$redis = Redis::connection();
$start = microtime(true);
$redis->ping();
$latency = (int) round((microtime(true) - $start) * 1000);
$info = $redis->info();
// Flatten if grouped (Predis often returns sections as sub-arrays)
$flatInfo = [];
foreach ($info as $key => $value) {
if (is_array($value)) {
$flatInfo = array_merge($flatInfo, $value);
} else {
$flatInfo[$key] = $value;
}
}
return [
'status' => 'connected',
'version' => $flatInfo['redis_version'] ?? 'Unknown',
'memory_used' => $flatInfo['used_memory_human'] ?? 'Unknown',
'clients' => $flatInfo['connected_clients'] ?? 0,
'latency' => $latency.'ms',
'uptime' => MonitoringFormatter::duration((int) ($flatInfo['uptime_in_seconds'] ?? 0)),
];
} catch (\Exception $e) {
return [
'status' => 'disconnected',
'error' => 'Connection failed',
'version' => 'Unknown',
'memory_used' => 'Unknown',
'clients' => 0,
'latency' => '0ms',
'uptime' => 'Unknown',
];
}
}
public function getRedisClients()
{
try {
$redis = Redis::connection();
$clientsRaw = $redis->executeRaw(['CLIENT', 'LIST']);
if (! is_string($clientsRaw)) {
return ['clients' => [], 'has_reverb' => false];
}
$lines = explode("\n", trim($clientsRaw));
$clients = [];
$hasReverb = false;
foreach ($lines as $line) {
if (empty($line)) {
continue;
}
$props = [];
foreach (explode(' ', $line) as $prop) {
if (strpos($prop, '=') !== false) {
[$k, $v] = explode('=', $prop, 2);
$props[$k] = $v;
}
}
// Identify client based on name or source
$addr = $props['addr'] ?? 'Unknown';
$name = $props['name'] ?: ($props['cmd'] === 'subscribe' ? 'Reverb' : 'Worker');
if (strpos($addr, '127.0.0.1') !== false) {
$type = 'Local Node';
} elseif ($props['cmd'] === 'subscribe') {
$type = 'WebSocket';
$hasReverb = true;
} else {
$type = 'Background';
}
$clients[] = [
'id' => $props['id'] ?? '?',
'name' => $name,
'type' => $type,
'addr' => $addr,
'age' => MonitoringFormatter::duration((int) ($props['age'] ?? 0)),
'idle' => MonitoringFormatter::duration((int) ($props['idle'] ?? 0)),
'db' => $props['db'] ?? '0',
'cmd' => $props['cmd'] ?? 'N/A',
];
}
return [
'clients' => $clients,
'has_reverb' => $hasReverb,
];
} catch (\Exception $e) {
return ['clients' => [], 'has_reverb' => false];
}
}
public function getQueueDetails()
{
try {
return DB::table('jobs')
->select('id', 'queue', 'payload', 'available_at')
->latest('available_at')
->take(10)
->get()
->map(function ($job) {
$payload = json_decode($job->payload);
return [
'id' => $job->id,
'name' => $payload->displayName ?? 'Unknown Job',
'queue' => $job->queue,
'time' => Carbon::createFromTimestamp($job->available_at)->diffForHumans(),
];
});
} catch (\Exception $e) {
return [];
}
}
// ========================
// App Health
// ========================
public function getAppHealth()
{
return [
'maintenance' => app()->isDownForMaintenance(),
'storage_link' => file_exists(public_path('storage')),
'env_safe' => ! config('app.debug'), // In production, debug should be false
'cache_active' => $this->checkCache(),
'reverb_active' => $this->checkReverbConnection(),
'logs_size' => MonitoringFormatter::bytes(file_exists(storage_path('logs/laravel.log')) ? filesize(storage_path('logs/laravel.log')) : 0),
];
}
private function checkCache()
{
try {
Cache::put('monitoring_check', true, 5);
return Cache::get('monitoring_check') === true;
} catch (\Exception $e) {
return false;
}
}
public function checkReverbConnection()
{
$host = config('reverb.servers.reverb.host', '127.0.0.1');
$port = config('reverb.servers.reverb.port', 8080);
return Cache::remember('monitoring_reverb_status', 30, function () use ($host, $port) {
// If localhost fails, try 'reverb' container name as fallback for Docker
$targets = [$host];
if ($host === 'localhost' || $host === '127.0.0.1') {
$targets[] = 'reverb';
}
foreach ($targets as $target) {
try {
$connection = @fsockopen($target, $port, $errno, $errstr, 0.2); // Reduced timeout to 0.2s
if (is_resource($connection)) {
fclose($connection);
return true;
}
} catch (\Exception $e) {
continue;
}
}
return false;
});
}
// ========================
// Recent Activity
// ========================
public function getRecentActivity()
{
if (! DB::getSchemaBuilder()->hasTable('activity_log')) {
return [];
}
return DB::table('activity_log')
->leftJoin('users', 'activity_log.causer_id', '=', 'users.id')
->select('activity_log.*', 'users.name as causer_name')
->latest()
->take(5)
->get()
->map(function ($item) {
return [
'description' => ucfirst(str_replace('_', ' ', $item->description)),
'subject' => $item->subject_type ? class_basename($item->subject_type) : 'System',
'causer' => $item->causer_name ?? 'System',
'time' => Carbon::parse($item->created_at)->diffForHumans(),
];
});
}
// ========================
// Backup Info
// ========================
public function getBackupStatus()
{
$backupDisk = config('backup.backup.destination.disks.0', 'local');
$backupName = config('backup.backup.name', 'laravel-backup');
return Cache::remember('monitoring_backup_status', 1800, function () use ($backupDisk, $backupName) {
try {
$files = Storage::disk($backupDisk)->files($backupName);
if (empty($files)) {
return ['last_backup' => 'Never', 'count' => 0];
}
$lastFile = end($files);
$timestamp = Storage::disk($backupDisk)->lastModified($lastFile);
return [
'last_backup' => Carbon::createFromTimestamp($timestamp)->diffForHumans(),
'count' => count($files),
'latest_file' => basename($lastFile),
];
} catch (\Exception $e) {
return ['last_backup' => 'Unknown', 'count' => 0];
}
});
}
// ========================
// SAP RFC Stats
// ========================
public function getSapStatus()
{
$possiblePaths = [
base_path('dev_rfc.trc'),
public_path('dev_rfc.trc'),
storage_path('logs/dev_rfc.trc'),
];
$exists = false;
$size = 0;
foreach ($possiblePaths as $path) {
if (file_exists($path)) {
$exists = true;
$size = filesize($path);
break;
}
}
return [
'active' => $exists,
'size' => MonitoringFormatter::bytes($size),
'status' => $exists ? 'TRACKING' : 'IDLE',
];
}
// ========================
// Mobile Stats
// ========================
public function getMobileStats()
{
$logFile = storage_path('logs/mobile.log');
$count = 0;
if (file_exists($logFile)) {
// Optimization: Use shell grep -c to count entries without loading file into memory
$count = (int) shell_exec("grep -c '^\[[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}' ".escapeshellarg($logFile));
}
return [
'total_logs' => $count,
'last_activity' => file_exists($logFile) ? Carbon::createFromTimestamp(filemtime($logFile))->diffForHumans() : 'Never',
];
}
// ========================
// Bundle All Metrics
// ========================
public function getAll()
{
return Cache::remember('monitoring_full_bundle', 10, function () {
$redisData = $this->getRedisClients();
return [
'os' => PHP_OS_FAMILY,
'maintenance' => app()->isDownForMaintenance(),
'hostname' => gethostname(),
'ip' => $this->getServerIp(),
'php' => $this->getPhpVersion(),
'db' => $this->getDatabaseVersion(),
'uptime' => $this->getUptime(),
'cpu' => $this->getCpuUsage(),
'ram' => $this->getFormattedRamUsage(),
'disk' => $this->getFormattedDiskUsage(),
'users' => $this->getActiveUsers(),
'total_users' => $this->getRegisteredUsers(),
'queues' => $this->getQueueStats(),
'db_stats' => $this->getDatabaseInfo(),
'redis' => $this->getRedisStats(),
'health' => $this->getAppHealth(),
'activity' => $this->getRecentActivity(),
'backup' => $this->getBackupStatus(),
'sap' => $this->getSapStatus(),
'mobile' => $this->getMobileStats(),
'redis_clients' => $redisData['clients'] ?? [],
'has_reverb' => $redisData['has_reverb'] ?? false,
'queue_details' => $this->getQueueDetails(),
'last_update' => now()->format('H:i:s'),
];
});
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Services\Notification;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TelegramService
{
/**
* Send a message to the configured Telegram Chat.
*/
public function sendMessage(string $message): bool
{
$token = get_setting('telegram_bot_token');
$chatId = get_setting('telegram_chat_id');
if (! $token || ! $chatId) {
Log::warning('Telegram Notification skipped: Bot Token or Chat ID not configured.');
return false;
}
try {
$response = Http::post("https://api.telegram.org/bot{$token}/sendMessage", [
'chat_id' => $chatId,
'text' => $message,
'parse_mode' => 'HTML',
'disable_web_page_preview' => true,
]);
if ($response->successful()) {
return true;
}
Log::error('Telegram API Error: '.$response->body());
return false;
} catch (\Exception $e) {
Log::error('Telegram Service Exception: '.$e->getMessage());
return false;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
namespace App\Services\System;
use Illuminate\Support\Str;
class ActivityFormatter
{
/**
* Get a friendly name for the model.
*/
public static function getFriendlyModelName(?string $model): string
{
if (! $model) {
return 'System';
}
$className = class_basename($model);
// Custom mapping for specific models
$mapping = [
'SystemSetting' => 'System Config',
'User' => 'User Profile',
'Role' => 'Access Role',
'Permission' => 'Access Permission',
'MobileSetting' => 'Mobile Config',
];
return $mapping[$className] ?? Str::headline($className);
}
/**
* Get badge class for the event.
*/
public static function getEventBadgeClass(string $event): string
{
return match (strtolower($event)) {
'created' => 'text-bg-success',
'updated' => 'text-bg-warning',
'deleted' => 'text-bg-danger',
'restored' => 'text-bg-info',
'login', 'login_attempt' => 'text-bg-info',
'logout' => 'text-bg-secondary',
'password_changed', 'password reset' => 'text-bg-primary',
default => 'text-bg-theme-1',
};
}
/**
* Get icon for the event.
*/
public static function getEventIcon(string $event): string
{
return match (strtolower($event)) {
'created' => 'bi-plus-circle',
'updated' => 'bi-pencil-square',
'deleted' => 'bi-trash',
'restored' => 'bi-arrow-counterclockwise',
'login', 'login_attempt' => 'bi-box-arrow-in-right',
'logout' => 'bi-box-arrow-right',
'password_changed', 'password reset' => 'bi-key',
default => 'bi-info-circle',
};
}
/**
* Formats the changes between old and new properties.
*/
public static function formatChanges(array $properties): array
{
$old = $properties['old'] ?? [];
$new = $properties['attributes'] ?? [];
$changes = [];
// If it's a "created" event, show all attributes
if (empty($old) && ! empty($new)) {
foreach ($new as $key => $value) {
if (self::isSensitive($key)) {
continue;
}
$changes[] = [
'field' => Str::headline($key),
'old' => null,
'new' => self::formatValue($value),
];
}
return $changes;
}
// For updates, show only changed fields
foreach ($new as $key => $value) {
if (self::isSensitive($key)) {
continue;
}
$oldValue = $old[$key] ?? null;
// Loose comparison to handle type juggling from DB
if ($oldValue != $value) {
$changes[] = [
'field' => Str::headline($key),
'old' => self::formatValue($oldValue),
'new' => self::formatValue($value),
];
}
}
return $changes;
}
private static function isSensitive(string $key): bool
{
$sensitive = ['password', 'remember_token', 'secret', 'key', 'token', '2fa_secret'];
foreach ($sensitive as $s) {
if (str_contains(strtolower($key), $s)) {
return true;
}
}
return false;
}
private static function formatValue(mixed $value): string
{
if (is_null($value)) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? 'TRUE' : 'FALSE';
}
if (is_array($value) || is_object($value)) {
return json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
if ($value === '') {
return '[empty]';
}
// Truncate long values but keep it readable
if (is_string($value) && strlen($value) > 200) {
return substr($value, 0, 197).'...';
}
return (string) $value;
}
}
@@ -0,0 +1,550 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\System;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\Monitoring\MonitoringFormatter;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Spatie\Backup\BackupDestination\Backup;
use Spatie\Backup\BackupDestination\BackupDestinationFactory;
use Spatie\Backup\Config\Config as SpatieConfig;
use Spatie\Backup\Notifications\Notifications\BackupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\BackupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\CleanupHasFailedNotification;
use Spatie\Backup\Notifications\Notifications\CleanupWasSuccessfulNotification;
use Spatie\Backup\Notifications\Notifications\HealthyBackupWasFoundNotification;
use Spatie\Backup\Notifications\Notifications\UnhealthyBackupWasFoundNotification;
use Symfony\Component\Process\Process;
class BackupManagementService
{
protected $configService;
public function __construct(SystemConfigService $configService)
{
$this->configService = $configService;
}
/**
* Apply database settings to Spatie Backup config at runtime.
*
* @param bool $skipNotifications Whether to skip setting up notifications (useful for manual UI backups)
* @param string|null $driver Optional driver to override the database setting
*/
public function applyDynamicConfig(bool $skipNotifications = false, ?string $driver = null)
{
$settings = $this->configService->all();
// 1. Storage Disks
$driver = $driver ?? get_setting('backup_db_driver', 'local');
Config::set('backup.backup.destination.disks', [$driver]);
Config::set('backup.monitor_backups.0.disks', [$driver]);
// Dynamic Cloud Storage Configuration
if ($driver === 'gdrive') {
Config::set('filesystems.disks.gdrive.clientId', $settings['gdrive_client_id'] ?? '');
Config::set('filesystems.disks.gdrive.clientSecret', $settings['gdrive_client_secret'] ?? '');
Config::set('filesystems.disks.gdrive.refreshToken', $settings['gdrive_refresh_token'] ?? '');
Config::set('filesystems.disks.gdrive.folder', $settings['gdrive_folder'] ?? 'LaravelBackups');
}
// 2. Cleanup Policy
$retention = (int) ($settings['backup_db_retention'] ?? 7);
Config::set('backup.cleanup.default_strategy.keep_all_backups_for_days', $retention);
// 3. Encryption
if (! empty($settings['backup_db_encrypt']) && ! empty($settings['backup_db_encrypt_key'])) {
Config::set('backup.backup.password', $settings['backup_db_encrypt_key']);
Config::set('backup.backup.encryption', 'aes256');
} else {
Config::set('backup.backup.password', null);
Config::set('backup.backup.encryption', 'none');
}
// 4. Notifications
$notifyOn = $settings['backup_db_notify_on'] ?? 'failed';
$notifyTo = $settings['backup_db_notify_to'] ?? '';
$channels = [];
if (! empty($notifyTo)) {
if (filter_var($notifyTo, FILTER_VALIDATE_EMAIL)) {
$channels[] = 'mail';
Config::set('backup.notifications.mail.to', $notifyTo);
} elseif (str_starts_with($notifyTo, 'http')) {
$channels[] = 'webhook';
Config::set('backup.notifications.notifications.webhook.url', $notifyTo);
}
}
if ($skipNotifications || $notifyOn === 'none') {
// Use empty channel arrays per-class (Spatie requires all keys to exist)
$allClasses = [
BackupHasFailedNotification::class,
UnhealthyBackupWasFoundNotification::class,
CleanupHasFailedNotification::class,
BackupWasSuccessfulNotification::class,
HealthyBackupWasFoundNotification::class,
CleanupWasSuccessfulNotification::class,
];
Config::set('backup.notifications.notifications', array_fill_keys($allClasses, []));
} else {
$map = [
'success' => [BackupWasSuccessfulNotification::class],
'failed' => [BackupHasFailedNotification::class],
'both' => [
BackupWasSuccessfulNotification::class,
BackupHasFailedNotification::class,
],
];
$classes = $map[$notifyOn] ?? $map['failed'];
$newNotifs = [];
foreach ($classes as $class) {
$newNotifs[$class] = $channels;
}
Config::set('backup.notifications.notifications', $newNotifs);
}
// 5. Exclude Tables (Injected via database config)
$dbDriver = config('database.default');
if (! empty($settings['backup_db_exclude'])) {
$excluded = array_map('trim', explode(',', $settings['backup_db_exclude']));
Config::set("database.connections.{$dbDriver}.dump.exclude_tables", $excluded);
}
// 6. DB-Specific Options (mysqldump / pg_dump)
if ($dbDriver === 'mysql') {
$extraOptions = ['--hex-blob'];
Config::set('database.connections.mysql.dump.add_extra_option', implode(' ', $extraOptions));
// Windows/Laragon mysqldump path fix
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$candidates = ['C:/laragon/bin/mysql/mysql-8.0.30-winx64/bin'];
$globMatches = glob('C:/laragon/bin/mysql/*/bin', GLOB_ONLYDIR);
if ($globMatches) {
$candidates = array_merge($candidates, $globMatches);
}
foreach ($candidates as $candidate) {
if (is_dir($candidate) && file_exists($candidate.'/mysqldump.exe')) {
Config::set('database.connections.mysql.dump.dump_binary_path', $candidate);
break;
}
}
}
} elseif ($dbDriver === 'pgsql') {
// Postgres specific dump options
// Spatie pg_dump usually works well with defaults, but we can add path if needed
if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') {
$globMatches = glob('C:/laragon/bin/postgresql/*/bin', GLOB_ONLYDIR);
if ($globMatches) {
Config::set('database.connections.pgsql.dump.dump_binary_path', end($globMatches));
}
}
}
}
public function getBackupList(bool $forceConfig = true, bool $forceRefresh = false)
{
if ($forceConfig) {
$this->applyDynamicConfig(true); // Don't need notifications just to list files
}
$cacheKey = 'system.backup_list';
// Helper function for the scan logic
$scan = function () {
$config = SpatieConfig::fromArray(config('backup'));
$backupDestinations = BackupDestinationFactory::createFromArray($config);
$backups = [];
foreach ($backupDestinations as $destination) {
foreach ($destination->backups() as $backup) {
$backups[] = [
'name' => $backup->path(),
'size' => $this->formatBytes($backup->sizeInBytes()),
'storage' => $destination->diskName(),
'date' => $backup->date()->format('Y-m-d H:i:s'),
'timestamp' => $backup->date()->timestamp,
'status' => 'Success',
];
}
}
// Sort by date descending
usort($backups, fn ($a, $b) => $b['timestamp'] <=> $a['timestamp']);
return $backups;
};
if ($forceRefresh) {
$data = $scan();
Cache::put($cacheKey, $data, 30);
return $data;
}
return Cache::remember($cacheKey, 30, $scan);
}
/**
* Get storage health statistics.
*/
public function getStorageStats(?string $driver = null)
{
$this->applyDynamicConfig(true, $driver);
$driver = $driver ?? config('backup.backup.destination.disks.0', 'local');
$total = 0;
$free = 0;
$used = 0;
$label = 'Local Disk';
try {
if ($driver === 'local') {
$total = @disk_total_space(storage_path('app')) ?: 0;
$free = @disk_free_space(storage_path('app')) ?: 0;
$used = $total - $free;
} else {
// For cloud, we might not get total/free easily depending on API
// We'll return just the used size and assume a default total for UI progress (e.g., 15GB for GDrive free)
$backups = $this->getBackupList(forceRefresh: false);
foreach ($backups as $b) {
if ($b['storage'] === $driver) {
$used += $this->parseBytes($b['size']);
}
}
$label = strtoupper($driver);
// Estimate total for progress bar visibility
$total = $driver === 'gdrive' ? (15 * 1024 * 1024 * 1024) : 0;
$free = max(0, $total - $used);
}
} catch (Exception $e) {
Log::error('Failed to get storage stats: '.$e->getMessage());
}
return [
'label' => $label,
'driver' => $driver,
'total' => $this->formatBytes($total),
'free' => $this->formatBytes($free),
'used' => $this->formatBytes($used),
'percentage' => $total > 0 ? round(($used / $total) * 100, 1) : 0,
'health' => ($total > 0 && ($free / $total) < 0.1) ? 'danger' : 'success',
'requirements' => $this->checkRequirements(),
];
}
/**
* Check if system requirements (binaries) for current driver are met.
*/
public function checkRequirements()
{
$dbDriver = config('database.default');
$binary = $dbDriver === 'pgsql' ? 'pg_dump' : ($dbDriver === 'mysql' ? 'mysqldump' : null);
if (! $binary) {
return ['status' => true];
}
$process = new Process(['which', $binary]);
$process->run();
$missing = ! $process->isSuccessful();
return [
'status' => ! $missing,
'binary' => $binary,
'message' => $missing ? __(':bin not found on server. Please install it to enable backups.', ['bin' => $binary]) : null,
];
}
/**
* Test connection to a specific storage disk.
*/
public function testConnection()
{
$this->applyDynamicConfig(true);
$disk = config('backup.backup.destination.disks.0', 'local');
try {
$backupName = config('backup.backup.name', 'biiproject');
// Pre-create directory for Google Drive to avoid reachability check failure
if ($disk === 'gdrive' && ! Storage::disk($disk)->exists($backupName)) {
Storage::disk($disk)->makeDirectory($backupName);
}
Storage::disk($disk)->files($backupName);
return [
'success' => true,
'message' => __('Successfully connected to :disk storage.', ['disk' => strtoupper($disk)]),
];
} catch (Exception $e) {
return [
'success' => false,
'message' => __('Connection failed: ').$e->getMessage(),
];
}
}
private function parseBytes(string $value): float
{
return MonitoringFormatter::parseBytes($value);
}
/**
* Create a new backup.
*
* Uses a child process instead of Artisan::call() to avoid inheriting the
* broken TCP/IP socket context from php artisan serve on Windows.
*/
public function createBackup()
{
$phpBinary = PHP_BINARY;
$artisan = base_path('artisan');
// Ensure dynamic config is applied in the current process
$this->applyDynamicConfig(true);
$disk = config('backup.backup.destination.disks.0', 'local');
$backupName = config('backup.backup.name', 'biiproject');
// Pre-create directory for Google Drive to avoid reachability check failure in child process
if ($disk === 'gdrive') {
try {
if (! Storage::disk($disk)->exists($backupName)) {
Storage::disk($disk)->makeDirectory($backupName);
}
} catch (Exception $e) {
Log::warning('Failed to pre-create GDrive backup directory: '.$e->getMessage());
}
}
$dbDriver = config('database.default');
$binaryPath = config("database.connections.{$dbDriver}.dump.dump_binary_path");
$env = getenv();
// Prepend binary path to PATH for child process
if ($binaryPath) {
$separator = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? ';' : ':';
$env['PATH'] = $binaryPath.$separator.($env['PATH'] ?? '');
}
// Add some basic required env vars if missing
$env['APP_ENV'] = app()->environment();
$env['APP_KEY'] = config('app.key');
$command = array_filter([
$phpBinary,
$artisan,
'backup:run',
'--only-db',
'--disable-notifications',
]);
$process = new Process($command, base_path(), $env);
$process->setTimeout(300); // 5 minutes max
Log::info('Backup: starting subprocess — '.implode(' ', $command));
$process->run();
$stdout = $process->getOutput();
$stderr = $process->getErrorOutput();
Log::info('Backup stdout: '.trim($stdout));
if ($stderr) {
Log::warning('Backup stderr: '.trim($stderr));
}
if (! $process->isSuccessful()) {
$detail = trim($stderr ?: $stdout ?: 'No output captured.');
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Backup Failed', "Database backup failed: {$detail}", 'warning', 'Developer')
);
throw new Exception(
"Backup failed (exit {$process->getExitCode()}): {$detail}"
);
}
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Backup Successful', 'Automated database backup completed successfully.', 'info', 'Developer')
);
// Bust the cache AFTER the file is confirmed written to disk
Cache::forget('system.backup_list');
return $stdout;
}
/**
* Restore from a backup file.
*/
public function restoreBackup($disk, $path)
{
$this->applyDynamicConfig();
if (! Storage::disk($disk)->exists($path)) {
throw new Exception("Backup file not found on disk: {$disk}");
}
// 1. Enable Maintenance Mode to prevent data corruption
Artisan::call('down', [
'--refresh' => 15,
'--secret' => 'restore-mode',
'--render' => 'errors::503',
]);
try {
// 2. Download/Copy to temp via Stream (Safer for large files)
$tempPath = storage_path('app/temp_restore.zip');
$diskStream = Storage::disk($disk)->readStream($path);
$localStream = fopen($tempPath, 'w+');
if (! $diskStream || ! $localStream) {
throw new Exception('Failed to open streams for restoration.');
}
stream_copy_to_stream($diskStream, $localStream);
fclose($localStream);
if (is_resource($diskStream)) {
fclose($diskStream);
}
// 3. Extract SQL from Zip (with Zip Slip protection)
$zip = new \ZipArchive;
$sqlFile = null;
$extractBase = realpath(storage_path('app'));
if ($zip->open($tempPath) === true) {
for ($i = 0; $i < $zip->numFiles; $i++) {
$filename = $zip->getNameIndex($i);
if (! str_ends_with($filename, '.sql')) {
continue;
}
// Prevent path traversal
$resolvedPath = $extractBase.DIRECTORY_SEPARATOR.ltrim($filename, '/\\');
$realResolved = realpath(dirname($resolvedPath));
if ($realResolved === false || ! str_starts_with($realResolved, $extractBase)) {
$zip->close();
throw new Exception("Invalid path in backup archive: {$filename}");
}
$zip->extractTo($extractBase, $filename);
$sqlFile = $extractBase.DIRECTORY_SEPARATOR.$filename;
break;
}
$zip->close();
}
if (! $sqlFile || ! file_exists($sqlFile)) {
throw new Exception('Could not find SQL file in the backup archive.');
}
// 4. Execute Restore using array-based Process with Streamed Input
$dbDriver = config('database.default');
$dbConfig = config("database.connections.{$dbDriver}");
$binPath = config("database.connections.{$dbDriver}.dump.dump_binary_path", '');
$env = array_merge(getenv(), [
'SystemRoot' => getenv('SystemRoot') ?: 'C:\WINDOWS',
'SystemDrive' => getenv('SystemDrive') ?: 'C:',
'windir' => getenv('windir') ?: 'C:\WINDOWS',
]);
if ($dbDriver === 'mysql') {
$command = [
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'mysql' : 'mysql',
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
'--port='.($dbConfig['port'] ?? '3306'),
'--user='.($dbConfig['username'] ?? ''),
'--password='.($dbConfig['password'] ?? ''),
$dbConfig['database'] ?? '',
];
} elseif ($dbDriver === 'pgsql') {
$command = [
$binPath ? rtrim($binPath, '/\\').DIRECTORY_SEPARATOR.'psql' : 'psql',
'--host='.($dbConfig['host'] ?? '127.0.0.1'),
'--port='.($dbConfig['port'] ?? '5432'),
'--username='.($dbConfig['username'] ?? 'postgres'),
'--dbname='.($dbConfig['database'] ?? ''),
];
$env['PGPASSWORD'] = $dbConfig['password'] ?? '';
} else {
throw new Exception('Restore currently only supports mysql or pgsql drivers.');
}
// High-performance streamed restore: open SQL file and pass the handle to Process
$sqlHandle = fopen($sqlFile, 'r');
$process = new Process($command, base_path(), $env, $sqlHandle);
$process->setTimeout(1200); // 20 minutes for very large DBs
$process->run();
if (is_resource($sqlHandle)) {
fclose($sqlHandle);
}
// Cleanup temp files
@unlink($tempPath);
@unlink($sqlFile);
if (! $process->isSuccessful()) {
throw new Exception('Restore execution failed: '.($process->getErrorOutput() ?: $process->getOutput() ?: 'Check logs.'));
}
// 5. Clear Caches & Optimization
Artisan::call('optimize:clear');
return true;
} catch (Exception $e) {
throw $e;
} finally {
// 6. Ensure system comes back UP even if restore failed
Artisan::call('up');
}
}
protected function formatBytes(int|float $bytes, int $precision = 2): string
{
return MonitoringFormatter::bytes($bytes, $precision);
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*/
namespace App\Services\System;
use App\Models\User;
use App\Services\SystemConfig\SettingDefinitions;
class GlobalSearchService
{
/**
* Search for users, pages, and settings.
*/
public function search(string $query): array
{
$results = [];
$query = strtolower(trim($query));
if (empty($query)) {
return $results;
}
// 1. Search Users
$users = User::where('name', 'ILIKE', "%{$query}%")
->orWhere('email', 'ILIKE', "%{$query}%")
->orWhere('username', 'ILIKE', "%{$query}%")
->take(5)
->get()
->map(function ($user) {
return [
'title' => $user->name,
'subtitle' => 'User: '.$user->email,
'url' => route('users').'?search='.urlencode($user->email),
'icon' => 'bi-person',
'category' => 'Users',
];
});
$results = array_merge($results, $users->toArray());
// 2. Search Pages (Menus)
$pages = [
['title' => 'Dashboard', 'url' => route('dashboard'), 'icon' => 'bi-speedometer2'],
['title' => 'User Directory', 'url' => route('users'), 'icon' => 'bi-people'],
['title' => 'Access Rights (Roles)', 'url' => route('roles'), 'icon' => 'bi-shield-lock'],
['title' => 'Permissions', 'url' => route('permissions'), 'icon' => 'bi-key'],
['title' => 'Action Logs', 'url' => route('action-logs'), 'icon' => 'bi-journal-text'],
['title' => 'Notification Center', 'url' => route('notification-center.index'), 'icon' => 'bi-bell'],
['title' => 'Health & Logs', 'url' => route('system-monitoring'), 'icon' => 'bi-activity'],
['title' => 'Session Manager', 'url' => route('session-manager'), 'icon' => 'bi-person-badge'],
['title' => 'Global Settings', 'url' => route('system-config'), 'icon' => 'bi-gear'],
['title' => 'Mobile Settings', 'url' => route('mobile-settings.index'), 'icon' => 'bi-phone'],
['title' => 'Backup & Storage', 'url' => route('backup-restore.index'), 'icon' => 'bi-cloud-download'],
['title' => 'Maintenance Mode', 'url' => route('maintenance-mode'), 'icon' => 'bi-wrench'],
['title' => 'AI Self-Healing', 'url' => route('ai-self-healing.index'), 'icon' => 'bi-heart-pulse'],
['title' => 'My Profile', 'url' => route('profile.edit'), 'icon' => 'bi-person-circle'],
];
foreach ($pages as $page) {
if (str_contains(strtolower($page['title']), $query)) {
$page['category'] = 'Pages';
$page['subtitle'] = 'Navigation';
$results[] = $page;
}
}
// 3. Search Settings (from SettingDefinitions)
$settingsResults = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$title = str_replace('_', ' ', $key);
$description = $meta['description'];
if (str_contains(strtolower($title), $query) || str_contains(strtolower($description), $query)) {
$settingsResults[] = [
'title' => ucwords($title),
'subtitle' => 'Setting: '.$description,
'url' => route('system-config').'?anchor='.$meta['group'],
'icon' => 'bi-sliders',
'category' => 'Settings',
];
}
if (count($settingsResults) >= 10) {
break;
}
}
$results = array_merge($results, $settingsResults);
// 4. AI Assistance Trigger
if ((str_ends_with($query, '?') || strlen($query) > 10) && auth()->user()->can('use ai assistant')) {
$results[] = [
'title' => 'Ask AI Assistant',
'subtitle' => 'Query: "'.$query.'"',
'url' => '#ask-ai',
'icon' => 'bi-robot',
'category' => 'AI Help',
'is_ai' => true,
'query' => $query,
];
}
return $results;
}
}
@@ -0,0 +1,203 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\System;
use App\Events\SystemNotification;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class MaintenanceManagementService
{
public function __construct(
protected SystemConfigService $configService
) {}
/**
* Sync the physical maintenance state with the database configuration.
*/
public function syncState()
{
try {
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$isCurrentlyDown = $this->isDown();
if ($enabled) {
$this->activateMaintenance($settings);
// Only notify if it was NOT already down
if (! $isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning')
);
}
} else {
$this->deactivateMaintenance();
// Only notify if it WAS previously down
if ($isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success')
);
}
}
return true;
} catch (Exception $e) {
Log::error('Maintenance Mode Sync Error: '.$e->getMessage());
return false;
}
}
/**
* Activate Laravel's native maintenance mode with dynamic parameters.
*/
protected function activateMaintenance(array $settings)
{
$options = [];
// 1. Secret Bypass URL
if (! empty($settings['maintenance_mode_secret'])) {
$options['--secret'] = $settings['maintenance_mode_secret'];
}
// 2. Refresh (Browser refresh interval)
$options['--refresh'] = 60; // Default 60 seconds
// 3. Retry After Header
if (! empty($settings['maintenance_mode_retry'])) {
$options['--retry'] = (int) $settings['maintenance_mode_retry'];
}
// 4. Allowed IPs
if (! empty($settings['maintenance_mode_allowed_ips'])) {
// Convert textarea lines/commas to array
$ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY);
if (! empty($ips)) {
$options['--allow'] = $ips;
}
}
// 5. Status Code (Always 503 for maintenance)
$options['--status'] = 503;
Log::info('System: Activating Maintenance Mode', $options);
// Note: Laravel 11+ down command handles these options
Artisan::call('down', $options);
}
/**
* Deactivate maintenance mode.
*/
protected function deactivateMaintenance()
{
if (app()->isDownForMaintenance()) {
Log::info('System: Deactivating Maintenance Mode');
Artisan::call('up');
}
}
/**
* Check if the system is currently in maintenance mode.
*/
public function isDown(): bool
{
return app()->isDownForMaintenance();
}
/**
* Automatically release maintenance mode if the end time has passed.
*/
public function autoCheckAndRelease(): void
{
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$endAt = $settings['maintenance_mode_end_at'] ?? null;
if (! $enabled || empty($endAt)) {
return;
}
try {
$endTime = new \DateTime($endAt);
$now = new \DateTime;
if ($now >= $endTime) {
Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [
'expired_at' => $endAt,
'current_time' => $now->format('Y-m-d H:i:s'),
]);
// Update configuration in DB
$this->configService->update([
'maintenance_mode_enabled' => false,
'maintenance_mode_end_at' => null, // Optional: Clear the end time
]);
// Physical release (artisan up)
$this->syncState();
}
} catch (Exception $e) {
Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage());
}
}
/**
* Broadcast a real-time warning to all active users via WebSockets.
*/
public function broadcastWarning(int $minutes)
{
$message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]);
// 1. Broadcast event for real-time UI notification (WebSockets)
event(new SystemNotification(
message: $message,
type: 'warning',
title: __('Maintenance Warning')
));
// 2. Persistent notification in the database
Notification::send(
User::all(), // Notify everyone
new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer')
);
Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m).");
return true;
}
}
@@ -0,0 +1,204 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\System;
use App\Events\SystemNotification;
use App\Models\User;
use App\Notifications\SystemManagementNotification;
use App\Services\SystemConfig\SystemConfigService;
use Exception;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class MaintenanceManagementService
{
public function __construct(
protected SystemConfigService $configService
) {}
/**
* Sync the physical maintenance state with the database configuration.
*/
public function syncState()
{
try {
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$isCurrentlyDown = $this->isDown();
if ($enabled) {
$this->activateMaintenance($settings);
// Only notify if it was NOT already down
if (! $isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('Maintenance Active', 'System is entering Maintenance Mode.', 'warning')
);
}
} else {
$this->deactivateMaintenance();
// Only notify if it WAS previously down
if ($isCurrentlyDown) {
Notification::send(
User::permission('view notification center')->get(),
new SystemManagementNotification('System Online', 'System is now LIVE and online.', 'success')
);
}
}
return true;
} catch (Exception $e) {
Log::error('Maintenance Mode Sync Error: '.$e->getMessage());
return false;
}
}
/**
* Activate Laravel's native maintenance mode with dynamic parameters.
*/
protected function activateMaintenance(array $settings)
{
$options = [];
// 1. Secret Bypass URL
if (! empty($settings['maintenance_mode_secret'])) {
$options['--secret'] = $settings['maintenance_mode_secret'];
}
// 2. Refresh (Browser refresh interval)
$options['--refresh'] = 60; // Default 60 seconds
// 3. Retry After Header
if (! empty($settings['maintenance_mode_retry'])) {
$options['--retry'] = (int) $settings['maintenance_mode_retry'];
}
// 4. Allowed IPs
if (! empty($settings['maintenance_mode_allowed_ips'])) {
// Convert textarea lines/commas to array
$ips = preg_split('/[\s,]+/', $settings['maintenance_mode_allowed_ips'], -1, PREG_SPLIT_NO_EMPTY);
if (! empty($ips)) {
$options['--allow'] = $ips;
}
}
// 5. Status Code (Always 503 for maintenance)
$options['--status'] = 503;
Log::info('System: Activating Maintenance Mode', $options);
// Note: Laravel 11+ down command handles these options
Artisan::call('down', $options);
}
/**
* Deactivate maintenance mode.
*/
protected function deactivateMaintenance()
{
if (app()->isDownForMaintenance()) {
Log::info('System: Deactivating Maintenance Mode');
Artisan::call('up');
}
}
/**
* Check if the system is currently in maintenance mode.
*/
public function isDown(): bool
{
return app()->isDownForMaintenance();
}
/**
* Automatically release maintenance mode if the end time has passed.
*/
public function autoCheckAndRelease(): void
{
$settings = $this->configService->all();
$enabled = filter_var($settings['maintenance_mode_enabled'] ?? false, FILTER_VALIDATE_BOOLEAN);
$endAt = $settings['maintenance_mode_end_at'] ?? null;
if (! $enabled || empty($endAt)) {
return;
}
try {
$endTime = new \DateTime($endAt);
$now = new \DateTime;
if ($now >= $endTime) {
Log::info('System: Maintenance window expired. Automatically deactivating maintenance mode.', [
'expired_at' => $endAt,
'current_time' => $now->format('Y-m-d H:i:s'),
]);
// Update configuration in DB
$this->configService->update([
'maintenance_mode_enabled' => false,
'maintenance_mode_end_at' => null, // Optional: Clear the end time
]);
// Physical release (artisan up)
$this->syncState();
}
} catch (Exception $e) {
Log::error('System: Automatic Maintenance Release failed: '.$e->getMessage());
}
}
/**
* Broadcast a real-time warning to all active users via WebSockets.
*/
public function broadcastWarning(int $minutes)
{
$message = __('SYSTEM ALERT: The system will enter maintenance mode in :min minutes. Please save your work immediately.', ['min' => $minutes]);
// 1. Broadcast event for real-time UI notification (WebSockets)
event(new SystemNotification(
title: __('Maintenance Warning'),
message: $message,
type: 'warning',
user_id: null // Broadcast to all
));
// 2. Persistent notification in the database
Notification::send(
User::all(), // Notify everyone
new SystemManagementNotification('Scheduled Maintenance', $message, 'warning', 'Developer')
);
Log::info("System: Broadcasted maintenance warning (Starting in {$minutes}m).");
return true;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Services\SystemConfig;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class SettingFileUploader
{
private const FIXED_PATHS = [
'app_logo' => 'assets/img/logo.png',
'app_favicon' => 'assets/img/favicon.png',
'maintenance_mode_image' => 'assets/img/maintenance.png',
];
public function replace(string $key, UploadedFile $file, mixed $oldValue): ?string
{
$dir = public_path('assets/img');
if (! is_dir($dir)) {
mkdir($dir, 0755, true);
}
if (isset(self::FIXED_PATHS[$key])) {
$filename = basename(self::FIXED_PATHS[$key]);
$file->move($dir, $filename);
return self::FIXED_PATHS[$key];
}
return $file->store('uploads/settings', 'public');
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Services\SystemConfig;
class SettingValueCaster
{
public static function normalize(mixed $value, string $type): mixed
{
return match ($type) {
'bool' => filter_var($value, FILTER_VALIDATE_BOOL),
'int' => $value === null || $value === '' ? null : (int) $value,
'float' => $value === null || $value === '' ? null : (float) $value,
'json' => is_array($value) ? $value : (json_decode((string) $value, true) ?: []),
'image_path' => is_string($value) ? trim($value) : null,
default => $value === null ? null : trim((string) $value),
};
}
public static function serialize(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_array($value)) {
return json_encode($value, JSON_UNESCAPED_SLASHES);
}
return (string) $value;
}
public static function deserialize(?string $value, string $type): mixed
{
if ($value === null) {
return null;
}
return match ($type) {
'bool' => $value === '1',
'int' => (int) $value,
'float' => (float) $value,
'json' => json_decode($value, true) ?: [],
default => $value,
};
}
public static function isUnchanged(mixed $oldValue, mixed $newValue): bool
{
return self::serialize($oldValue) === self::serialize($newValue);
}
}
@@ -0,0 +1,225 @@
<?php
/**
* ============================================================
*
* @project biiproject
*
* @author Andika Debi Putra
*
* @email andikadebiputra@gmail.com
*
* @website https://biiproject.com
*
* @copyright Copyright (c) 2026 Andika Debi Putra
* @license Proprietary - All Rights Reserved
*
* @version 1.0.0
*
* @created 2026-05-01
* ============================================================
*
* Unauthorized copying, modification, distribution, or use
* of this file is strictly prohibited without prior written
* permission from the author.
* ============================================================
*/
namespace App\Services\SystemConfig;
use App\Models\SystemSettingRevision;
use App\Repositories\SystemSettingRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class SystemConfigService
{
private const CACHE_KEY = 'system_settings.all';
private const PUBLIC_CACHE_KEY = 'system_settings.public';
private const CACHE_TTL_MINUTES = 60;
protected static ?array $resolvedSettings = null;
public function __construct(
protected SystemSettingRepository $repository,
protected SettingFileUploader $fileUploader = new SettingFileUploader,
) {}
public static function definitions(): array
{
return SettingDefinitions::ALL;
}
public function all(bool $forceRefresh = false): array
{
if ($forceRefresh) {
$this->invalidateCache();
}
if (self::$resolvedSettings !== null) {
return self::$resolvedSettings;
}
try {
return self::$resolvedSettings = Cache::remember(
self::CACHE_KEY,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => $this->resolveAll(),
);
} catch (\Exception) {
return self::$resolvedSettings = $this->resolveAll();
}
}
public function getPublicSettings(): array
{
try {
return Cache::remember(
self::PUBLIC_CACHE_KEY,
now()->addMinutes(self::CACHE_TTL_MINUTES),
fn () => $this->buildPublicSettings(),
);
} catch (\Exception) {
return $this->buildPublicSettings();
}
}
public function grouped(): array
{
$all = $this->all();
$grouped = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$grouped[$meta['group']][$key] = $all[$key] ?? $meta['default'] ?? null;
}
return $grouped;
}
public function get(string $key, mixed $default = null): mixed
{
if (! array_key_exists($key, SettingDefinitions::ALL)) {
return $default;
}
return $this->all()[$key] ?? $default;
}
public function update(array $input, array $files = [], ?int $actorId = null, ?Request $request = null): void
{
$current = $this->all();
foreach (SettingDefinitions::ALL as $key => $meta) {
$oldValue = $current[$key] ?? $meta['default'] ?? null;
$newValue = $this->resolveNewValue($key, $meta, $input, $files, $oldValue);
if (SettingValueCaster::isUnchanged($oldValue, $newValue)) {
continue;
}
$setting = $this->repository->upsert([
'key' => $key,
'value' => SettingValueCaster::serialize($newValue),
'type' => $meta['type'],
'group' => $meta['group'],
'is_public' => (bool) $meta['is_public'],
'description' => $meta['description'],
'created_by' => $actorId,
'updated_by' => $actorId,
]);
$this->writeRevision($setting->id, $key, $oldValue, $newValue, $actorId, $request);
}
$this->invalidateCache();
}
protected function resolveNewValue(string $key, array $meta, array $input, array $files, mixed $oldValue): mixed
{
if ($meta['type'] === 'image_path') {
return isset($files[$key])
? $this->fileUploader->replace($key, $files[$key], $oldValue)
: $oldValue;
}
return array_key_exists($key, $input)
? SettingValueCaster::normalize($input[$key], (string) $meta['type'])
: $oldValue;
}
protected function resolveAll(): array
{
try {
if (! $this->repository->tableExists()) {
return $this->definitionDefaults();
}
$rows = $this->repository->all();
} catch (\Exception) {
return $this->definitionDefaults();
}
$resolved = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$record = $rows->firstWhere('key', $key);
$resolved[$key] = $record
? SettingValueCaster::deserialize($record->value, (string) $record->type)
: ($meta['default'] ?? null);
}
return $resolved;
}
protected function buildPublicSettings(): array
{
$all = $this->all();
$public = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
if (! $meta['is_public']) {
continue;
}
$public[$key] = $all[$key] ?? $meta['default'] ?? null;
}
return $public;
}
protected function definitionDefaults(): array
{
$defaults = [];
foreach (SettingDefinitions::ALL as $key => $meta) {
$defaults[$key] = $meta['default'] ?? null;
}
return $defaults;
}
protected function writeRevision(
int $settingId,
string $key,
mixed $oldValue,
mixed $newValue,
?int $actorId,
?Request $request,
): void {
SystemSettingRevision::query()->create([
'system_setting_id' => $settingId,
'key' => $key,
'old_value' => $oldValue === null ? null : json_encode($oldValue, JSON_UNESCAPED_SLASHES),
'new_value' => $newValue === null ? null : json_encode($newValue, JSON_UNESCAPED_SLASHES),
'changed_by' => $actorId,
'changed_ip' => $request?->ip(),
'changed_agent' => $request?->userAgent(),
'created_at' => now(),
]);
}
protected function invalidateCache(): void
{
Cache::forget(self::CACHE_KEY);
Cache::forget(self::PUBLIC_CACHE_KEY);
self::$resolvedSettings = null;
}
}