feat: add app and database modules
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user