Initial commit

This commit is contained in:
root
2026-05-09 17:28:23 +02:00
commit 9d73f82529
5575 changed files with 281989 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\AlertChannel;
use App\Enums\AlertSeverity;
use App\Enums\AlertType;
use App\Models\Miscellaneous\AlertLog;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Throwable;
class AlertService
{
private bool $emailEnabled;
private bool $discordEnabled;
private string $discordWebhookUrl;
private string $alertEmail;
private readonly string $siteName;
public function __construct()
{
$this->emailEnabled = (bool) setting('alert_email_enabled', false);
$this->discordEnabled = (bool) setting('alert_discord_enabled', false);
$this->discordWebhookUrl = setting('alert_discord_webhook_url', '');
$this->alertEmail = setting('alert_email_address', '');
$this->siteName = setting('hotel_name', config('app.name', 'Atom CMS'));
}
public function send(
AlertType $type,
string $message,
array $context = [],
AlertChannel $channel = AlertChannel::BOTH,
): void {
$this->logAlert($type, $message, $context);
$channels = $this->resolveChannels($channel);
foreach ($channels as $ch) {
match ($ch) {
AlertChannel::EMAIL => $this->sendEmail($type, $message, $context),
AlertChannel::DISCORD => $this->sendDiscord($type, $message, $context),
default => null,
};
}
}
public function sendCritical(AlertType $type, string $message, array $context = []): void
{
$this->send($type, $message, $context, AlertChannel::BOTH);
}
public function sendEmulatorOffline(string $error = ''): void
{
$this->send(
AlertType::EMULATOR_OFFLINE,
'De emulator is offline of niet bereikbaar via RCON.',
['error' => $error],
);
}
public function sendEmulatorOnline(): void
{
$this->send(
AlertType::EMULATOR_ONLINE,
'De emulator is weer online en verbonden via RCON.',
[],
AlertChannel::BOTH,
);
}
public function sendDDoSDetected(array $details): void
{
$message = sprintf(
'Mogelijke DDoS aanval gedetecteerd! %d requests in %d seconden van %d unieke IPs.',
$details['total_requests'] ?? 0,
$details['time_window'] ?? 0,
$details['unique_ips'] ?? 0,
);
$this->send(AlertType::DDOS_DETECTED, $message, $details);
}
public function sendCriticalError(string $error, ?Throwable $exception = null): void
{
$context = [
'error' => $error,
'exception_class' => $exception instanceof Throwable ? $exception::class : null,
'file' => $exception?->getFile(),
'line' => $exception?->getLine(),
'trace' => $exception instanceof Throwable ? $exception->getTraceAsString() : null,
];
$this->send(AlertType::CRITICAL_ERROR, $error, $context);
}
public function sendQueueFailed(string $job, array $details = []): void
{
$this->send(
AlertType::QUEUE_FAILED,
"Queue job gefaald: {$job}",
array_merge(['job' => $job], $details),
);
}
public function sendHighErrorRate(int $errorCount, int $threshold, int $timeWindow): void
{
$this->send(
AlertType::HIGH_ERROR_RATE,
"Hoge error rate gedetecteerd: {$errorCount} errors in {$timeWindow} minuten (drempel: {$threshold})",
['error_count' => $errorCount, 'threshold' => $threshold, 'time_window' => $timeWindow],
);
}
public function sendEmulatorUpdate(string $version, string $details = ''): void
{
$this->send(
AlertType::EMULATOR_UPDATE,
"Emulator succesvol geüpdatet naar versie {$version}",
['version' => $version, 'details' => $details],
AlertChannel::DISCORD,
);
}
public function sendSqlUpdate(int $count, string $details = ''): void
{
$this->send(
AlertType::SQL_UPDATE,
"{$count} SQL update(s) succesvol toegepast",
['count' => $count, 'details' => $details],
AlertChannel::DISCORD,
);
}
private function resolveChannels(AlertChannel $preferred): array
{
$channels = [];
if (($preferred === AlertChannel::BOTH || $preferred === AlertChannel::EMAIL) && ($this->emailEnabled && ($this->alertEmail !== '' && $this->alertEmail !== '0'))) {
$channels[] = AlertChannel::EMAIL;
}
if (($preferred === AlertChannel::BOTH || $preferred === AlertChannel::DISCORD) && ($this->discordEnabled && ($this->discordWebhookUrl !== '' && $this->discordWebhookUrl !== '0'))) {
$channels[] = AlertChannel::DISCORD;
}
return $channels;
}
private function sendEmail(AlertType $type, string $message, array $context): void
{
if (! $this->emailEnabled || ($this->alertEmail === '' || $this->alertEmail === '0')) {
Log::warning('Email alert skipped: not enabled or no email configured', [
'emailEnabled' => $this->emailEnabled,
'alertEmail' => $this->alertEmail,
]);
return;
}
try {
$severity = $type->getSeverity();
Mail::raw($this->buildEmailBody($type, $message, $context), function ($mail) use ($type, $severity) {
$mail->from(config('mail.from.address', 'noreply@' . request()->getHost()), $this->siteName)
->to($this->alertEmail)
->subject(sprintf(
'[%s] %s - %s',
config('app.env', 'Production'),
strtoupper($severity->value),
$type->getLabel(),
));
});
Log::info('Alert email sent successfully', [
'to' => $this->alertEmail,
'type' => $type->value,
]);
} catch (\Exception $e) {
Log::error('Failed to send alert email: ' . $e->getMessage(), [
'to' => $this->alertEmail,
'exception' => $e,
]);
}
}
private function buildEmailBody(AlertType $type, string $message, array $context): string
{
$severity = $type->getSeverity();
$timestamp = now()->format('Y-m-d H:i:s T');
$body = "{$severity->getEmoji()} ALERT: {$type->getLabel()}\n";
$body .= "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n";
$body .= "Bericht: {$message}\n";
$body .= "Tijd: {$timestamp}\n";
$body .= "Server: {$this->siteName}\n";
$body .= 'Environment: ' . config('app.env', 'Production') . "\n";
$body .= 'Severity: ' . ucfirst($severity->value) . "\n\n";
if ($context !== []) {
$body .= "Details:\n";
foreach ($context as $key => $value) {
if ($key === 'trace' && is_string($value)) {
$body .= " - {$key}: [Zie logs voor volledige trace]\n";
} else {
$body .= " - {$key}: " . (is_array($value) ? json_encode($value) : (string) $value) . "\n";
}
}
}
$body .= "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n";
return $body . "Dit is een automatisch gegenereerd bericht van {$this->siteName}.\n";
}
private function sendDiscord(AlertType $type, string $message, array $context): void
{
if (! $this->discordEnabled || ($this->discordWebhookUrl === '' || $this->discordWebhookUrl === '0')) {
return;
}
try {
$severity = $type->getSeverity();
$color = $this->getDiscordColor($severity);
$embed = [
'title' => "{$severity->getEmoji()} {$type->getLabel()}",
'description' => $message,
'color' => $color,
'fields' => [
[
'name' => 'Server',
'value' => $this->siteName,
'inline' => true,
],
[
'name' => 'Environment',
'value' => config('app.env', 'Production'),
'inline' => true,
],
[
'name' => 'Tijd',
'value' => now()->format('Y-m-d H:i:s T'),
'inline' => true,
],
],
'footer' => [
'text' => 'Atom CMS Alert System',
],
'timestamp' => now()->toIso8601String(),
];
if ($context !== []) {
$details = [];
foreach (array_slice($context, 0, 5, true) as $key => $value) {
if (in_array($key, ['trace', 'stack_trace'])) {
continue;
}
$details[] = [
'name' => ucfirst(str_replace('_', ' ', $key)),
'value' => mb_substr((string) (is_array($value) ? json_encode($value) : $value), 0, 1024),
'inline' => false,
];
}
$embed['fields'] = array_merge($embed['fields'], $details);
}
Http::post($this->discordWebhookUrl, [
'username' => 'Atom CMS Alerts',
'embeds' => [$embed],
]);
} catch (\Exception $e) {
Log::error('Failed to send Discord alert: ' . $e->getMessage());
}
}
private function getDiscordColor(AlertSeverity $severity): int
{
return match ($severity) {
AlertSeverity::INFO => 3447003,
AlertSeverity::WARNING => 16776960,
AlertSeverity::ERROR => 15158332,
AlertSeverity::CRITICAL => 10038562,
};
}
private function logAlert(AlertType $type, string $message, array $context = []): void
{
try {
AlertLog::create([
'type' => $type->value,
'severity' => $type->getSeverity()->value,
'message' => $message,
'context' => $context,
'sent_via_email' => $this->emailEnabled && ($this->alertEmail !== '' && $this->alertEmail !== '0'),
'sent_via_discord' => $this->discordEnabled && ($this->discordWebhookUrl !== '' && $this->discordWebhookUrl !== '0'),
]);
} catch (\Exception $e) {
Log::error('Failed to log alert: ' . $e->getMessage());
}
}
public function testAlert(): array
{
Cache::forget('website_settings');
$this->refreshSettings();
$results = [];
if ($this->emailEnabled && ($this->alertEmail !== '' && $this->alertEmail !== '0')) {
try {
$this->send(
AlertType::EMULATOR_ONLINE,
'Dit is een testmelding van het Atom CMS Alert Systeem.',
['test' => true],
AlertChannel::EMAIL,
);
$results['email'] = 'success';
} catch (\Exception $e) {
$results['email'] = 'failed: ' . $e->getMessage();
}
}
if ($this->discordEnabled && ($this->discordWebhookUrl !== '' && $this->discordWebhookUrl !== '0')) {
try {
$this->send(
AlertType::EMULATOR_ONLINE,
'Dit is een testmelding van het Atom CMS Alert Systeem.',
['test' => true],
AlertChannel::DISCORD,
);
$results['discord'] = 'success';
} catch (\Exception $e) {
$results['discord'] = 'failed: ' . $e->getMessage();
}
}
return $results;
}
private function refreshSettings(): void
{
$this->emailEnabled = (bool) setting('alert_email_enabled', false);
$this->discordEnabled = (bool) setting('alert_discord_enabled', false);
$this->discordWebhookUrl = setting('alert_discord_webhook_url', '');
$this->alertEmail = setting('alert_email_address', '');
}
public static function clearCache(): void
{
Cache::forget('alert_settings');
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Services\Articles;
use App\Models\Articles\WebsiteArticle;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
readonly class ArticleService
{
public function getArticles(bool $paginate = false, int $perPage = 8): array|Collection|LengthAwarePaginator
{
$query = WebsiteArticle::query()->with(['user' => function ($query) {
$query->select('id', 'username', 'look');
}])->orderByDesc('id');
return $paginate ? $query->paginate($perPage) : $query->get();
}
public function fetchArticle(string $slug): WebsiteArticle
{
return WebsiteArticle::query()->where('slug', '=', $slug)->firstOrFail();
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Articles;
use App\Models\Articles\WebsiteArticle;
use App\Models\Articles\WebsiteArticleComment;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
readonly class CommentService
{
public function store(string $comment, WebsiteArticle $article): mixed
{
if ($article->userHasReachedArticleCommentLimit()) {
return redirect()->back()->withErrors([
'message' => __('You can only comment :amount times per article', ['amount' => setting('max_comment_per_article')]),
]);
}
if (! $article->can_comment) {
return redirect()->back()->withErrors([
'message' => __('This article has been locked from receiving comments'),
]);
}
return $article->comments()->create([
'user_id' => Auth::id(),
'comment' => $comment,
]);
}
public function destroy(WebsiteArticleComment $comment): bool|RedirectResponse|null
{
if (! $comment->canBeDeleted()) {
return redirect()->back()->withErrors([
'message' => __('You can only delete your own comments'),
]);
}
if (! $comment->delete()) {
return redirect()->back()->withErrors([
'message' => __('An error occurred while deleting the comment'),
]);
}
return $comment->delete();
}
}
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Articles;
use App\Models\Articles\WebsiteArticle;
use App\Models\Articles\WebsiteArticleReaction;
use App\Models\User;
use Illuminate\Http\Request;
readonly class ReactionService
{
public function toggleReaction(WebsiteArticle $article, User $user, Request $request): array
{
$reaction = $request->get('reaction');
if (! is_string($reaction) || ! in_array($reaction, config('habbo.reactions'))) {
return ['success' => false];
}
$existingReaction = WebsiteArticleReaction::getReaction($article->id, $user->id, $reaction);
if ($existingReaction instanceof WebsiteArticleReaction) {
$existingReaction->update(['active' => ! $existingReaction->active]);
} else {
$article->reactions()->create([
'reaction' => $reaction,
]);
}
return [
'success' => true,
'added' => $existingReaction->active ?? true,
'username' => $user->username,
];
}
}
+724
View File
@@ -0,0 +1,724 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Process;
class AutoDetectService
{
private static ?self $instance = null;
private array $cache = [];
public static function getInstance(): self
{
return self::$instance ??= new self;
}
// ─── Emulator ──────────────────────────────────────────────
public function detectEmulatorJarPath(): string
{
return $this->cache['emulator_jar_path'] ??= $this->doDetectEmulatorJarPath();
}
private function doDetectEmulatorJarPath(): string
{
// 1. Check setting
$setting = $this->getSetting('emulator_jar_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
// 2. Check well-known directories
$dirNames = ['Emulator', 'emulator', 'emulator-source', 'arcturus', 'morningstar', 'hotel', 'habbo-emulator', 'nitro-emulator'];
$found = $this->smartSearch($dirNames, ['*.jar'], ['/var/www', '/opt', '/home', '/root', '/srv', base_path()]);
if ($found) {
return $found;
}
// 3. Find any directory containing a JAR
$result = Process::timeout(10)->run(
'find /var/www /opt /home /root /srv -maxdepth 4 -name "*.jar" -type f 2>/dev/null | head -1',
);
if ($result->successful()) {
$jarFile = trim($result->output());
if ($jarFile !== '' && str_ends_with($jarFile, '.jar')) {
return dirname($jarFile);
}
}
return '/root/emulator';
}
public function detectEmulatorSourcePath(): string
{
return $this->cache['emulator_source_path'] ??= $this->doDetectEmulatorSourcePath();
}
private function doDetectEmulatorSourcePath(): string
{
$setting = $this->getSetting('emulator_source_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$dirNames = ['emulator-source', 'emulator', 'arcturus-source', 'morningstar-source', 'Arcturus-Emulator', 'Morningstar', 'hotel-source'];
$found = $this->smartSearch($dirNames, ['pom.xml', 'build.gradle', 'src'], ['/var/www', '/opt', '/home', '/root', '/srv', base_path()]);
if ($found) {
return $found;
}
return '/var/www/emulator-source';
}
public function detectEmulatorServiceName(): string
{
return $this->cache['emulator_service'] ??= $this->doDetectEmulatorServiceName();
}
private function doDetectEmulatorServiceName(): string
{
$setting = $this->getSetting('emulator_service_name');
if ($setting && $setting !== '' && $setting !== '0') {
// Verify it actually exists
$check = Process::timeout(5)->run(
'systemctl list-unit-files ' . escapeshellarg($setting) . '.service 2>/dev/null | grep -q ' . escapeshellarg($setting) . ' && echo found',
);
if ($check->successful() && trim($check->output()) === 'found') {
return $setting;
}
}
// Try common service names
$possibleServices = [
'emulator', 'arcturus', 'morningstar', 'arcturus-emulator',
'habbo', 'hotel', 'game', 'nitro-emulator', 'hotel-server',
'arcturus-emu', 'morningstar-emu',
];
foreach ($possibleServices as $service) {
$check = Process::timeout(5)->run(
'systemctl list-unit-files ' . escapeshellarg($service) . '.service 2>/dev/null | grep -q ' . escapeshellarg($service) . ' && echo found',
);
if ($check->successful() && trim($check->output()) === 'found') {
return $service;
}
}
// Also check running services
$running = Process::timeout(5)->run('systemctl list-units --type=service --state=running --no-legend 2>/dev/null');
if ($running->successful()) {
$lines = explode("\n", $running->output());
foreach ($lines as $line) {
foreach ($possibleServices as $service) {
if (str_contains($line, $service)) {
return $service;
}
}
}
}
// Check for any Java process that looks like an emulator
$javaProc = Process::timeout(5)->run("ps aux | grep -i 'java.*jar\\|arcturus\\|morningstar\\|emulator' | grep -v grep | head -1");
if ($javaProc->successful() && trim($javaProc->output()) !== '') {
$output = trim($javaProc->output());
// Try to extract service name from systemd
if (preg_match('/systemd.*--name[=\s]+(\S+)/', $output, $matches)) {
return $matches[1];
}
}
return 'emulator';
}
public function detectEmulatorJavaProcess(): ?array
{
$result = Process::timeout(5)->run(
"ps aux | grep -i '[j]ava.*jar' | head -1",
);
if (! $result->successful() || trim($result->output()) === '') {
return null;
}
$line = trim($result->output());
$parts = preg_split('/\s+/', $line);
return [
'pid' => $parts[1] ?? null,
'full_command' => $line,
];
}
// ─── Nitro Client / Renderer ───────────────────────────────
public function detectNitroClientPath(): string
{
return $this->cache['nitro_client_path'] ??= $this->doDetectNitroClientPath();
}
private function doDetectNitroClientPath(): string
{
$setting = $this->getSetting('nitro_client_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$dirNames = ['nitro-client', 'nitro-client-v3', 'nitro-V3', 'nitro-V3-client', 'Nitro-V3', 'Nitro_Client', 'nitro', 'client'];
$found = $this->smartSearch($dirNames, ['package.json', 'src'], ['/var/www', '/opt', '/home', '/root', base_path(), base_path() . '/..']);
if ($found) {
return $found;
}
return base_path() . '/nitro-client';
}
public function detectNitroRendererPath(): string
{
return $this->cache['nitro_renderer_path'] ??= $this->doDetectNitroRendererPath();
}
private function doDetectNitroRendererPath(): string
{
$setting = $this->getSetting('nitro_renderer_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$dirNames = ['nitro-renderer', 'nitro-render-v3', 'Nitro_Render_V3', 'Nitro-Render-V3', 'nitro-render', 'renderer'];
$found = $this->smartSearch($dirNames, ['package.json', 'src'], ['/var/www', '/opt', '/home', '/root', base_path(), base_path() . '/..']);
if ($found) {
return $found;
}
return base_path() . '/nitro-renderer';
}
public function detectNitroWebroot(): string
{
return $this->cache['nitro_webroot'] ??= $this->doDetectNitroWebroot();
}
private function doDetectNitroWebroot(): string
{
$setting = $this->getSetting('nitro_webroot');
if ($setting && $this->exists($setting)) {
return $setting;
}
// Check nginx config for root directives
$nginxRoot = $this->detectNginxWebroot();
if ($nginxRoot) {
return $nginxRoot;
}
$possiblePaths = [
'/var/www/Client', '/var/www/client', '/var/www/Nitro', '/var/www/nitro',
'/var/www/html/Client', '/var/www/html/client',
'/var/www/atomcms/public/Client', '/var/www/atomcms/public/nitro',
];
foreach ($possiblePaths as $path) {
if ($this->exists($path)) {
return $path;
}
}
return '/var/www/Client';
}
public function detectNitroBuildPath(): string
{
return $this->cache['nitro_build_path'] ??= $this->doDetectNitroBuildPath();
}
private function doDetectNitroBuildPath(): string
{
$setting = $this->getSetting('nitro_build_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$clientPath = $this->detectNitroClientPath();
$distPath = $clientPath . '/dist';
if ($this->exists($distPath)) {
return $distPath;
}
$possiblePaths = [
'/var/www/nitro-client/dist', '/var/www/nitro-V3/dist',
'/var/www/atomcms/nitro-client/dist',
];
foreach ($possiblePaths as $path) {
if ($this->exists($path)) {
return $path;
}
}
return $clientPath . '/dist';
}
// ─── Gamedata ──────────────────────────────────────────────
public function detectGamedataPath(): string
{
return $this->cache['gamedata_path'] ??= $this->doDetectGamedataPath();
}
private function doDetectGamedataPath(): string
{
// Always scan first to detect actual case on disk
$result = Process::timeout(5)->run('ls -1 /var/www/ 2>/dev/null | grep -i "^gamedata$"');
if ($result->successful() && trim($result->output()) !== '') {
$actualDir = trim($result->output());
$path = '/var/www/' . $actualDir;
if ($this->exists($path)) {
// Save detected path to setting for future use
$this->saveSetting('gamedata_path', $path);
return $path;
}
}
// Fallback to setting if scan didn't find it
$setting = $this->getSetting('gamedata_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
// Last resort: check common paths
$possiblePaths = [
'/var/www/atomcms/public/gamedata', '/var/www/html/gamedata',
'/opt/gamedata', '/root/gamedata',
];
foreach ($possiblePaths as $path) {
if ($this->exists($path)) {
return $path;
}
}
return '/var/www/gamedata';
}
public function detectFurnitureIconsPath(): string
{
$setting = $this->getSetting('furniture_icons_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$gamedata = $this->detectGamedataPath();
$iconsPath = $gamedata . '/icons';
if ($this->exists($iconsPath)) {
return $iconsPath;
}
return '/var/www/Gamedata/icons';
}
public function detectCatalogIconsPath(): string
{
$setting = $this->getSetting('catalog_icons_path');
if ($setting && $this->exists($setting)) {
return $setting;
}
$gamedata = $this->detectGamedataPath();
$cataloguePath = $gamedata . '/catalogue';
if ($this->exists($cataloguePath)) {
return $cataloguePath;
}
return '/var/www/Gamedata/catalogue';
}
// ─── Nginx ─────────────────────────────────────────────────
public function detectNginxConfigPath(): ?string
{
$possibleConfigs = [
'/etc/nginx/sites-enabled/cms.conf',
'/etc/nginx/sites-enabled/atom.conf',
'/etc/nginx/sites-enabled/default',
'/etc/nginx/conf.d/atom.conf',
'/etc/nginx/conf.d/cms.conf',
'/etc/nginx/conf.d/default.conf',
];
foreach ($possibleConfigs as $config) {
if (file_exists($config)) {
return $config;
}
}
return null;
}
public function detectNginxWebroot(): ?string
{
$configPath = $this->detectNginxConfigPath();
if (! $configPath) {
return null;
}
$content = @file_get_contents($configPath);
if (! $content) {
return null;
}
// Look for root directives that point to Client/nitro directories
if (preg_match_all('/^\s*root\s+(.+);/m', $content, $matches)) {
foreach ($matches[1] as $root) {
$root = trim($root);
if ((str_contains(strtolower($root), 'client') || str_contains(strtolower($root), 'nitro')) && $this->exists($root)) {
return $root;
}
}
// Return first valid root
foreach ($matches[1] as $root) {
$root = trim($root);
if ($this->exists($root)) {
return $root;
}
}
}
return null;
}
public function detectGamedataUrlPath(): ?string
{
$configPath = $this->detectNginxConfigPath();
if (! $configPath) {
return null;
}
$content = @file_get_contents($configPath);
if (! $content) {
return null;
}
// Look for location /gamedata { alias /var/www/Gamedata; }
// Return the location path (the URL path), not the alias path
if (preg_match('/location\s+(\/gamedata)\s*\{/i', $content, $matches)) {
return $matches[1]; // Returns /gamedata
}
// Fallback: check if there's a root that includes gamedata
if (preg_match_all('/^\s*root\s+(.+);/m', $content, $matches)) {
foreach ($matches[1] as $root) {
$root = trim($root);
if (str_contains(strtolower($root), 'gamedata')) {
$segments = explode('/', trim($root, '/'));
return '/' . end($segments);
}
}
}
return null;
}
// ─── PHP Configuration ─────────────────────────────────────
public function detectPhpVersion(): string
{
$result = Process::timeout(5)->run('php -r "echo PHP_MAJOR_VERSION.\'.\'.PHP_MINOR_VERSION;"');
if ($result->successful()) {
return trim($result->output());
}
return '8.2';
}
public function detectPhpIniPath(): ?string
{
$result = Process::timeout(5)->run('php -i 2>/dev/null | grep "Loaded Configuration File" | cut -d">" -f2 | xargs');
if ($result->successful()) {
$path = trim($result->output());
if (file_exists($path)) {
return $path;
}
}
return null;
}
public function detectPhpFpmService(): ?string
{
$version = $this->detectPhpVersion();
$candidates = [
"php{$version}-fpm",
'php-fpm',
'php8.3-fpm', 'php8.2-fpm', 'php8.1-fpm', 'php8.0-fpm', 'php7.4-fpm',
];
foreach ($candidates as $service) {
$check = Process::timeout(5)->run(
'systemctl list-unit-files ' . escapeshellarg($service) . '.service 2>/dev/null | grep -q ' . escapeshellarg($service) . ' && echo found',
);
if ($check->successful() && trim($check->output()) === 'found') {
return $service;
}
}
return null;
}
// ─── Java ──────────────────────────────────────────────────
public function detectJavaVersion(): ?string
{
$result = Process::timeout(5)->run('java -version 2>&1 | head -1');
if ($result->successful()) {
return trim($result->output());
}
return null;
}
public function detectJavaPath(): ?string
{
$result = Process::timeout(5)->run('which java 2>/dev/null');
if ($result->successful()) {
$path = trim($result->output());
if ($path !== '') {
return $path;
}
}
// Check common locations
$commonPaths = [
'/usr/bin/java', '/usr/local/bin/java',
'/usr/lib/jvm/default/bin/java',
];
foreach ($commonPaths as $path) {
if (file_exists($path)) {
return $path;
}
}
return null;
}
// ─── System Tools ──────────────────────────────────────────
public function detectGitPath(): ?string
{
$result = Process::timeout(5)->run('which git 2>/dev/null');
return $result->successful() ? trim($result->output()) : null;
}
public function detectNodePath(): ?string
{
$result = Process::timeout(5)->run('which node 2>/dev/null');
return $result->successful() ? trim($result->output()) : null;
}
public function detectYarnPath(): ?string
{
$result = Process::timeout(5)->run('which yarn 2>/dev/null');
return $result->successful() ? trim($result->output()) : null;
}
public function detectNpmPath(): ?string
{
$result = Process::timeout(5)->run('which npm 2>/dev/null');
return $result->successful() ? trim($result->output()) : null;
}
public function detectMavenPath(): ?string
{
$result = Process::timeout(5)->run('which mvn 2>/dev/null || which mvnw 2>/dev/null');
if ($result->successful()) {
$path = trim($result->output());
if ($path !== '') {
return $path;
}
}
// Check source path for mvnw
$sourcePath = $this->detectEmulatorSourcePath();
$mvnw = $sourcePath . '/mvnw';
if (file_exists($mvnw)) {
return $mvnw;
}
return null;
}
public function detectGradlePath(): ?string
{
$result = Process::timeout(5)->run('which gradle 2>/dev/null');
if ($result->successful()) {
$path = trim($result->output());
if ($path !== '') {
return $path;
}
}
$sourcePath = $this->detectEmulatorSourcePath();
$gradlew = $sourcePath . '/gradlew';
if (file_exists($gradlew)) {
return $gradlew;
}
return null;
}
// ─── Full Detection ────────────────────────────────────────
public function detectAll(): array
{
return [
// Emulator
'emulator_jar_path' => $this->detectEmulatorJarPath(),
'emulator_source_path' => $this->detectEmulatorSourcePath(),
'emulator_service_name' => $this->detectEmulatorServiceName(),
'emulator_java_process' => $this->detectEmulatorJavaProcess(),
// Nitro
'nitro_client_path' => $this->detectNitroClientPath(),
'nitro_renderer_path' => $this->detectNitroRendererPath(),
'nitro_webroot' => $this->detectNitroWebroot(),
'nitro_build_path' => $this->detectNitroBuildPath(),
// Gamedata
'gamedata_path' => $this->detectGamedataPath(),
'furniture_icons_path' => $this->detectFurnitureIconsPath(),
'catalog_icons_path' => $this->detectCatalogIconsPath(),
// System
'nginx_config_path' => $this->detectNginxConfigPath(),
'php_version' => $this->detectPhpVersion(),
'php_ini_path' => $this->detectPhpIniPath(),
'php_fpm_service' => $this->detectPhpFpmService(),
'java_version' => $this->detectJavaVersion(),
'java_path' => $this->detectJavaPath(),
// Tools
'git_path' => $this->detectGitPath(),
'node_path' => $this->detectNodePath(),
'yarn_path' => $this->detectYarnPath(),
'npm_path' => $this->detectNpmPath(),
'maven_path' => $this->detectMavenPath(),
'gradle_path' => $this->detectGradlePath(),
];
}
public function clearCache(): void
{
$this->cache = [];
}
// ─── Private Helpers ───────────────────────────────────────
private function smartSearch(array $dirNames, array $validators, array $searchDirs): ?string
{
// Phase 1: exact directory name match with validator check
foreach ($searchDirs as $searchDir) {
if (! $this->exists($searchDir)) {
continue;
}
foreach ($dirNames as $dirName) {
$fullPath = rtrim((string) $searchDir, '/') . '/' . $dirName;
if (! $this->exists($fullPath)) {
continue;
}
foreach ($validators as $validator) {
if (str_contains((string) $validator, '*')) {
$result = Process::timeout(5)->run(
'find ' . escapeshellarg($fullPath) . ' -maxdepth 2 -name ' . escapeshellarg((string) $validator) . ' 2>/dev/null | head -1',
);
if ($result->successful() && trim($result->output()) !== '') {
return $fullPath;
}
} elseif ($this->exists($fullPath . '/' . $validator)) {
return $fullPath;
}
}
}
}
// Phase 2: deep search via find
foreach ($searchDirs as $searchDir) {
if (! $this->exists($searchDir)) {
continue;
}
$namePattern = implode('|', $dirNames);
$result = Process::timeout(15)->run(
'find ' . escapeshellarg((string) $searchDir) . ' -maxdepth 4 -type d 2>/dev/null | grep -iE "(' . implode('|', array_map(preg_quote(...), $dirNames)) . ')" | head -5',
);
if ($result->successful()) {
$lines = explode("\n", trim($result->output()));
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
foreach ($validators as $validator) {
if (str_contains((string) $validator, '*')) {
$check = Process::timeout(5)->run(
'find ' . escapeshellarg($line) . ' -maxdepth 2 -name ' . escapeshellarg((string) $validator) . ' 2>/dev/null | head -1',
);
if ($check->successful() && trim($check->output()) !== '') {
return $line;
}
} elseif ($this->exists($line . '/' . $validator)) {
return $line;
}
}
}
}
}
return null;
}
private function exists(string $path): bool
{
$result = Process::timeout(5)->run(
'test -e ' . escapeshellarg($path) . ' && echo yes || echo no',
);
return trim($result->output()) === 'yes';
}
private function getSetting(string $key): ?string
{
try {
$settings = resolve(SettingsService::class);
$value = $settings->getOrDefault($key, '');
return ($value !== '' && $value !== '0') ? $value : null;
} catch (\Exception) {
return null;
}
}
private function saveSetting(string $key, string $value): void
{
try {
$settings = resolve(SettingsService::class);
$settings->set($key, $value);
} catch (\Exception) {
// Silently fail - detection still works without saving
}
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Redis;
readonly class CacheService
{
private const array CACHE_KEYS = [
'website_settings' => 'website_settings',
'website_permissions' => 'website_permissions',
'points_service_settings' => 'points_service_settings',
'radio_leaderboard' => 'radio_leaderboard*',
'radio_leaderboard_weekly' => 'radio_leaderboard_weekly',
'radio_leaderboard_monthly' => 'radio_leaderboard_monthly',
'blocked_words' => 'blocked_words',
'content_moderation_settings' => 'content_moderation_settings',
'radio_settings_general' => 'radio_settings_general',
'radio_settings_dj' => 'radio_settings_dj',
'radio_ranks_active' => 'radio_ranks_active',
'radio_banners_active' => 'radio_banners_active',
'radio_shouts_recent' => 'radio_shouts_recent',
'rare_value_categories' => 'rare_value_categories',
'rare_value_categories_active' => 'rare_value_categories_active',
'staff_positions' => 'staff_positions',
'staff_ids' => 'staff_ids',
'hotel_teams' => 'hotel_teams',
];
private static function useRedis(): bool
{
return config('cache.default') === 'redis';
}
public static function clearWebsiteSettings(): void
{
self::forget('website_settings');
}
public static function clearWebsitePermissions(): void
{
self::forget('website_permissions');
}
public static function clearPointsSettings(): void
{
self::forget('points_service_settings');
}
public static function clearPointsUser(int $userId): void
{
self::forget('user_points_' . $userId);
}
public static function clearLeaderboards(): void
{
self::forgetPattern('radio_leaderboard*');
}
public static function clearContentModeration(): void
{
self::forget('blocked_words');
self::forget('content_moderation_settings');
}
public static function clearRadioSettings(): void
{
self::forget('radio_settings_general');
self::forget('radio_settings_dj');
self::forget('radio_ranks_active');
self::forget('radio_banners_active');
}
public static function clearRadioShouts(): void
{
self::forget('radio_shouts_recent');
}
public static function clearRareValueCategories(): void
{
self::forget('rare_value_categories');
self::forget('rare_value_categories_active');
}
public static function clearStaff(): void
{
self::forget('staff_positions');
self::forget('staff_ids');
self::forget('hotel_teams');
}
public static function clearApiCache(): void
{
self::forgetPattern('api_cache_*');
}
public static function clearLeaderboard(): void
{
self::forget('leaderboard_credits');
self::forget('leaderboard_duckets');
self::forget('leaderboard_diamonds');
self::forget('leaderboard_online');
self::forget('leaderboard_respects');
self::forget('leaderboard_achievements');
}
public static function clearHelpCenter(): void
{
self::forget('help_center_categories');
self::forget('website_rules_categories');
}
public static function clearAllAppCache(): void
{
foreach (self::CACHE_KEYS as $key) {
if (str_contains($key, '*')) {
self::forgetPattern($key);
} else {
self::forget($key);
}
}
}
private static function forget(string $key): void
{
try {
$prefix = config('cache.prefix', 'atomcms_cache_');
Cache::forget($prefix . $key);
} catch (\Exception) {
Cache::forget($key);
}
}
private static function forgetPattern(string $pattern): void
{
if (self::useRedis()) {
try {
$connection = config('cache.stores.redis.connection', 'cache');
$prefix = config('cache.prefix', 'atomcms_cache_');
$fullPattern = $prefix . $pattern;
$redis = Redis::connection($connection)->client();
$keys = $redis->keys($fullPattern);
if (! empty($keys)) {
$redis->del($keys);
}
return;
} catch (\Exception) {
}
}
$keys = self::CACHE_KEYS;
foreach ($keys as $key) {
if (fnmatch($pattern, $key, FNM_NOESCAPE)) {
self::forget($key);
}
}
}
}
+1103
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Services\Community;
use App\Models\Miscellaneous\CameraWeb;
readonly class CameraService
{
public function fetchPhotos(bool $paginate = false, int $perPage = 8): mixed
{
$photos = CameraWeb::query()->where('visible', true)
->latest('id')
->with('user:id,username,look');
return $paginate ? $photos->paginate($perPage) : $photos->get();
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Services\Community\RareValues;
use App\Models\Community\RareValue\WebsiteRareValueCategory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
readonly class RareValueCategoriesService
{
private const int CACHE_TTL = 300; // 5 minutes
private const int PAGINATION_PER_PAGE = 12;
public function fetchAllCategories(): Collection
{
return Cache::remember('rare_categories_all', self::CACHE_TTL, fn () => WebsiteRareValueCategory::orderBy('priority')->get());
}
public function fetchCategoriesByPriority(): Builder|Collection
{
return Cache::remember('rare_categories_priority', self::CACHE_TTL, fn () => WebsiteRareValueCategory::query()
->orderBy('priority')
->with(['furniture' => function ($query) {
$query->orderBy('name');
}])
->get());
}
public function fetchCategoryById(int $id): ?WebsiteRareValueCategory
{
return Cache::remember("rare_category_{$id}", self::CACHE_TTL, function () use ($id) {
/** @var WebsiteRareValueCategory|null $result */
$result = WebsiteRareValueCategory::with(['furniture' => function ($query) {
$query->orderBy('name');
}])
->where('id', $id)
->first();
return $result;
});
}
public function searchCategories(string $searchTerm): Collection
{
$cacheKey = 'rare_search_' . md5($searchTerm);
return Cache::remember($cacheKey, self::CACHE_TTL, fn () => WebsiteRareValueCategory::with(['furniture' => function ($query) use ($searchTerm) {
$query->where('name', 'like', '%' . $searchTerm . '%')
->orderBy('name');
}])
->whereHas('furniture', function ($query) use ($searchTerm) {
$query->where('name', 'like', '%' . $searchTerm . '%');
})
->orderBy('priority')
->get());
}
public function getRaresWithPagination(int $categoryId, string $sortBy = 'name', string $sortOrder = 'asc'): LengthAwarePaginator
{
$cacheKey = "rare_category_{$categoryId}_page_" . request('page', 1) . "_{$sortBy}_{$sortOrder}";
return Cache::remember($cacheKey, self::CACHE_TTL, fn () => WebsiteRareValueCategory::find($categoryId)
?->furniture()
->orderBy($sortBy, $sortOrder)
->paginate(self::PAGINATION_PER_PAGE) ?? collect()->paginate(self::PAGINATION_PER_PAGE));
}
public function getRareStatistics(): array
{
return Cache::remember('rare_statistics', self::CACHE_TTL, function () {
$categories = WebsiteRareValueCategory::withCount('furniture')->get();
return [
'total_categories' => $categories->count(),
'total_rares' => $categories->sum('furniture_count'),
'most_valuable_category' => $categories->sortByDesc('furniture_count')->first()?->name ?? 'N/A',
'average_rares_per_category' => $categories->avg('furniture_count') ?? 0,
];
});
}
public function clearCache(): void
{
Cache::forget('rare_categories_all');
Cache::forget('rare_categories_priority');
Cache::forget('rare_statistics');
// Clear individual category caches
WebsiteRareValueCategory::pluck('id')->each(function ($id) {
Cache::forget("rare_category_{$id}");
});
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Services\Community;
use App\Models\Game\Permission;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
readonly class StaffService
{
public function fetchStaffPositions(): Collection
{
$cacheEnabled = setting('enable_caching') === '1';
if ($cacheEnabled && Cache::has('staff_positions')) {
return Cache::get('staff_positions');
}
$minStaffRank = (int) setting('min_staff_rank', 3);
$minRankToSeeHidden = (int) setting('min_rank_to_see_hidden_staff', 7);
$userRank = Auth::check() ? Auth::user()->rank : 0;
$employees = Permission::query()
->select('id', 'rank_name', 'badge', 'staff_color', 'job_description')
->when($userRank < $minRankToSeeHidden, fn ($query) => $query->where('hidden_rank', false))
->where('id', '>=', $minStaffRank)
->orderByDesc('id')
->with(['users' => function ($query) use ($minRankToSeeHidden) {
$query->select('id', 'username', 'rank', 'motto', 'look', 'hidden_staff', 'online')
->when(Auth::check() && Auth::user()->rank < $minRankToSeeHidden, fn ($query) => $query->where('hidden_staff', false));
}])
->get();
if ($cacheEnabled) {
$cacheTimer = (int) setting('cache_timer');
Cache::put('staff_positions', $employees, now()->addMinutes($cacheTimer));
}
return $employees;
}
public function fetchEmployeeIds(): array
{
$cacheEnabled = setting('enable_caching') === '1';
if ($cacheEnabled && Cache::has('staff_ids')) {
return Cache::get('staff_ids');
}
$minRank = (int) setting('min_staff_rank', 3);
$staffIds = User::query()->select('id')
->where('rank', '>=', $minRank)
->get()
->pluck('id')->toArray();
if ($cacheEnabled) {
$cacheTimer = (int) setting('cache_timer');
Cache::put('staff_ids', $staffIds, now()->addMinutes($cacheTimer));
}
return $staffIds;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Community;
use App\Models\Community\Teams\WebsiteTeam;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
readonly class TeamService
{
public function fetchTeams(): Collection
{
$cacheEnabled = setting('enable_caching') === '1';
if ($cacheEnabled && Cache::has('hotel_teams')) {
return Cache::get('hotel_teams');
}
$employees = WebsiteTeam::query()->select([
'id',
'rank_name',
'badge',
'staff_color',
'staff_background',
'job_description',
])
->where('hidden_rank', false)
->orderByDesc('id')
->with(['users' => function ($query) {
$query->select('id', 'username', 'look', 'motto', 'rank', 'team_id', 'online');
}])
->get();
if ($cacheEnabled) {
$cacheTimer = (int) setting('cache_timer');
Cache::put('hotel_teams', $employees, now()->addMinutes($cacheTimer));
}
return $employees;
}
}
+274
View File
@@ -0,0 +1,274 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsiteSetting;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
readonly class ContentModerationService
{
private const int MODERATION_CACHE_DURATION_MINUTES = 5;
private const string SETTINGS_CACHE_KEY = 'content_moderation_settings';
private const int SETTINGS_CACHE_DURATION = 300;
private const string BLOCKED_WORDS_CACHE_KEY = 'blocked_words';
private string $openAiApiKey;
private bool $useOpenAI;
private float $sensitivity;
/** @var array<mixed> */
private array $blockedWords;
public function __construct()
{
$this->openAiApiKey = config('services.openai.api_key', '');
$this->useOpenAI = $this->openAiApiKey !== '';
$this->sensitivity = (float) ($this->getSetting('ai_filter_sensitivity') ?? 0.8);
$this->blockedWords = $this->loadBlockedWords();
}
public function moderate(string $content, string $type = 'general'): array
{
$trimmedContent = trim($content);
if ($trimmedContent === '') {
return [
'approved' => true,
'score' => 0,
'reason' => null,
'detected_words' => [],
];
}
$localResult = $this->localModerate($trimmedContent);
if ($localResult['blocked']) {
return [
'approved' => false,
'score' => 1.0,
'reason' => 'Blocked content detected',
'detected_words' => $localResult['words'],
'method' => 'local',
];
}
if ($this->useOpenAI) {
return $this->openAIModerate($trimmedContent, $type);
}
return [
'approved' => true,
'score' => $localResult['score'],
'reason' => null,
'detected_words' => [],
'method' => 'local',
];
}
private function localModerate(string $content): array
{
$text = strtolower(strip_tags($content));
$detectedWords = array_filter(
$this->blockedWords,
fn (string $word): bool => stripos($text, $word) !== false,
);
if ($detectedWords !== []) {
return [
'blocked' => true,
'words' => array_values($detectedWords),
'score' => 1.0,
];
}
$suspiciousPatterns = [
'/(.)\1{4,}/i',
'/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/',
'/\b(\w*?[\x27\`]+\w*?)+\b/i',
];
$patternScore = array_reduce(
$suspiciousPatterns,
fn (float $score, string $pattern): float => preg_match($pattern, $text) ? $score + 0.3 : $score,
0.0,
);
return [
'blocked' => $patternScore >= $this->sensitivity,
'words' => [],
'score' => min($patternScore, 1.0),
];
}
private function openAIModerate(string $content, string $type): array
{
$cacheKey = 'moderation_' . md5($content . $type);
return Cache::remember($cacheKey, now()->addMinutes(self::MODERATION_CACHE_DURATION_MINUTES), function () use ($content): array {
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer ' . $this->openAiApiKey,
'Content-Type' => 'application/json',
])->post('https://api.openai.com/v1/moderations', [
'input' => $content,
]);
if ($response->successful()) {
$result = $response->json();
$moderationResult = $result['results'][0];
$categories = $moderationResult['categories'];
$categoryScores = $moderationResult['category_scores'];
$maxScore = 0.0;
$triggeredCategories = [];
foreach ($categories as $category => $flagged) {
$categoryScore = $categoryScores[$category] ?? 0;
if ($flagged || $categoryScore > $this->sensitivity) {
$triggeredCategories[] = $category;
$maxScore = max($maxScore, $categoryScore);
}
}
$approved = $triggeredCategories === [] || $maxScore < $this->sensitivity;
return [
'approved' => $approved,
'score' => $maxScore,
'reason' => $approved ? null : implode(', ', $triggeredCategories),
'detected_words' => $moderationResult['flagged_tokens'] ?? [],
'method' => 'openai',
'categories' => $triggeredCategories,
];
}
} catch (\Exception $e) {
report($e);
}
return [
'approved' => true,
'score' => 0,
'reason' => null,
'detected_words' => [],
'method' => 'fallback',
];
});
}
public function addBlockedWord(string $word): bool
{
$cleanWord = strtolower(trim($word));
$key = 'filter_word_' . $cleanWord;
WebsiteSetting::updateOrCreate(
['key' => $key],
['value' => '1', 'comment' => 'Blocked word: ' . $word],
);
Cache::forget(self::BLOCKED_WORDS_CACHE_KEY);
return true;
}
public function removeBlockedWord(string $word): bool
{
$cleanWord = strtolower(trim($word));
$key = 'filter_word_' . $cleanWord;
WebsiteSetting::where('key', $key)->delete();
Cache::forget(self::BLOCKED_WORDS_CACHE_KEY);
return true;
}
/**
* @return list<string>
*/
public function getBlockedWords(): array
{
return $this->blockedWords;
}
public function setSensitivity(float $sensitivity): void
{
$newSensitivity = max(0, min(1, $sensitivity));
WebsiteSetting::updateOrCreate(
['key' => 'ai_filter_sensitivity'],
['value' => (string) $newSensitivity, 'comment' => 'AI filter sensitivity (0-1)'],
);
Cache::forget(self::SETTINGS_CACHE_KEY);
CacheService::clearContentModeration();
}
public function getSensitivity(): float
{
return $this->sensitivity;
}
public function isUsingOpenAI(): bool
{
return $this->useOpenAI;
}
public function isEnabled(): bool
{
return (bool) $this->getSetting('ai_filter_enabled');
}
public function shouldAutoReject(): bool
{
return (bool) $this->getSetting('ai_filter_auto_reject');
}
public function getStats(): array
{
return [
'enabled' => $this->isEnabled(),
'auto_reject' => $this->shouldAutoReject(),
'method' => $this->useOpenAI ? 'openai' : 'local',
'sensitivity' => $this->sensitivity,
'blocked_words_count' => count($this->blockedWords),
];
}
/**
* @return list<string>
*/
private function loadBlockedWords(): array
{
return Cache::remember(self::BLOCKED_WORDS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::where('key', 'like', 'filter_word_%')
->where('value', '1')
->pluck('key')
->map(fn (string $key): string => str_replace('filter_word_', '', $key))
->values()
->toArray());
}
private function getSetting(string $key): ?string
{
return $this->getSettings()[$key] ?? null;
}
/**
* @return array<string, string|null>
*/
private function getSettings(): array
{
return Cache::remember(self::SETTINGS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::whereIn('key', [
'ai_filter_sensitivity',
'ai_filter_enabled',
'ai_filter_auto_reject',
])->pluck('value', 'key')->toArray());
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Services\Emulator\Concerns;
use Illuminate\Support\Facades\Schema;
trait HasPermissionColumns
{
protected function getPermissionColumnsFromSchema(): array
{
if (! Schema::hasTable('permissions')) {
return [];
}
$columns = Schema::getColumns('permissions');
return collect($columns)->filter(function (array $column) {
$columnName = $column['name'] ?? null;
if (! $columnName) {
return false;
}
return str_starts_with($columnName, 'cmd')
|| str_starts_with($columnName, 'acc')
|| str_ends_with($columnName, 'cmd');
})->values()->toArray();
}
}
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Services;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Cache;
/* Credits to Kani for this */
readonly class FindRetrosService
{
private const string FIND_RETROS_VERIFY_URI = '%s/validate.php?user=%s&ip=%s';
private const string FIND_RETROS_REDIRECT_URI = '%s/servers/%s/vote?minimal=1&return=1';
private const string FIND_RETROS_CACHE_KEY = 'voted.%s';
private Client $client;
public function __construct()
{
$this->client = new Client(['verify' => false]);
}
public function checkHasVoted(): bool
{
if (! config('habbo.findretros.enabled')) {
return true;
}
$ip = request()->ip();
if ($ip === '127.0.0.1') {
return true;
}
if (request()->has('novote')) {
return true;
}
$cacheKey = sprintf(self::FIND_RETROS_CACHE_KEY, $ip);
if (Cache::has($cacheKey)) {
return true;
}
$uri = sprintf(
self::FIND_RETROS_VERIFY_URI,
config('habbo.findretros.api'),
config('habbo.findretros.name'),
$ip,
);
$request = $this->client->get($uri);
$response = $request->getBody()->getContents();
if (in_array($response, ['1', '2'], true)) {
Cache::put($cacheKey, true, now()->addMinutes(30));
return true;
}
return false;
}
public function getRedirectUri(): string
{
return sprintf(
self::FIND_RETROS_REDIRECT_URI,
config('habbo.findretros.api'),
config('habbo.findretros.name'),
);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\WebsiteHousekeepingPermission;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
readonly class HousekeepingPermissionsService
{
private const string CACHE_KEY = 'housekeeping_permissions';
private const int CACHE_DURATION_MINUTES = 30;
private Collection $permissions;
public function __construct()
{
$this->permissions = Cache::remember(
self::CACHE_KEY,
now()->addMinutes(self::CACHE_DURATION_MINUTES),
fn (): Collection => WebsiteHousekeepingPermission::all()->pluck('min_rank', 'permission'),
);
}
public function getOrDefault(string $permissionName, bool $default = false): bool
{
if (! $this->permissions->has($permissionName)) {
return $default;
}
$requiredRank = (int) $this->permissions->get($permissionName);
return auth()->check() && auth()->user()->rank >= $requiredRank;
}
public static function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
readonly class InstallationService
{
private const string CACHE_KEY = 'app_installed';
public static function isComplete(): bool
{
if (Cache::has(self::CACHE_KEY)) {
return true;
}
try {
if (! Schema::hasTable('website_installation')) {
return false;
}
$installation = DB::table('website_installation')->first();
$isComplete = $installation !== null && (bool) $installation->completed;
if ($isComplete) {
Cache::rememberForever(self::CACHE_KEY, fn (): bool => true);
}
return $isComplete;
} catch (Throwable) {
return false;
}
}
public static function setComplete(): void
{
Cache::rememberForever(self::CACHE_KEY, fn (): bool => true);
}
public static function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
readonly class IpLookupService
{
private const array BLOCKLISTS = [
// FireHol - All Levels (werkt)
'firehol_level1' => 'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset',
'firehol_level2' => 'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level2.netset',
'firehol_level3' => 'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level3.netset',
'firehol_level4' => 'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level4.netset',
// Spamhaus (werkt)
'spamhaus_drop' => 'https://www.spamhaus.org/drop/drop.txt',
'spamhaus_edrop' => 'https://www.spamhaus.org/drop/edrop.txt',
// Threat Intelligence (werkt)
'dshield' => 'https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/dshield.netset',
'emerging_threats' => 'https://rules.emergingthreats.net/blockrules/compromised-ips.txt',
// Blocklist.de (werkt)
'blocklist_de_all' => 'https://lists.blocklist.de/lists/all.txt',
'blocklist_de_mail' => 'https://lists.blocklist.de/lists/mail.txt',
'blocklist_de_ssh' => 'https://lists.blocklist.de/lists/ssh.txt',
'blocklist_de_ftp' => 'https://lists.blocklist.de/lists/ftp.txt',
'blocklist_de_sip' => 'https://lists.blocklist.de/lists/sip.txt',
];
private const int CACHE_TIME = 43200;
public function getCountryInfo(string $ip): array
{
$cacheKey = "ip_country_{$ip}";
return Cache::remember($cacheKey, now()->addDays(7), function () use ($ip) {
try {
$response = Http::timeout(10)->get("http://ip-api.com/json/{$ip}");
if ($response->ok()) {
$data = $response->json();
return [
'country_code' => $data['countryCode'] ?? null,
'country_name' => $data['country'] ?? null,
'region' => $data['regionName'] ?? null,
'city' => $data['city'] ?? null,
'isp' => $data['isp'] ?? null,
'org' => $data['org'] ?? null,
'as' => $data['as'] ?? null,
];
}
} catch (\Exception $e) {
return ['error' => $e->getMessage()];
}
return ['error' => 'Failed to fetch'];
});
}
public function checkVpnProxyTor(string $ip): array
{
$cacheKey = "ip_check_{$ip}";
return Cache::remember($cacheKey, now()->addHours(6), function () use ($ip) {
$isBlocked = $this->isIpInAnyBlocklist($ip);
return [
'is_vpn' => $isBlocked,
'is_proxy' => $isBlocked,
'is_tor' => $isBlocked,
'is_malicious' => $isBlocked,
'source' => 'multi_blocklist',
];
});
}
private function isIpInAnyBlocklist(string $ip): bool
{
foreach (self::BLOCKLISTS as $name => $url) {
$cidrs = $this->getBlocklistCidrs($name, $url);
foreach ($cidrs as $cidr) {
if ($this->ipInCidr($ip, $cidr)) {
return true;
}
}
}
return false;
}
private function getBlocklistCidrs(string $name, string $url): array
{
$cacheKey = "cidr_blocklist_{$name}";
return Cache::remember($cacheKey, now()->addHours(self::CACHE_TIME), function () use ($url) {
try {
$response = Http::timeout(120)->get($url);
if (! $response->ok()) {
return [];
}
$lines = explode("\n", $response->body());
$cidrs = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line === '0' || str_starts_with($line, '#') || str_starts_with($line, ';')) {
continue;
}
if (str_contains($line, '/')) {
$cidrs[] = $line;
} elseif (filter_var($line, FILTER_VALIDATE_IP)) {
$cidrs[] = $line . '/32';
}
}
return $cidrs;
} catch (\Exception) {
return [];
}
});
}
private function ipInCidr(string $ip, string $cidr): bool
{
if (! str_contains($cidr, '/')) {
return $ip === $cidr;
}
try {
[$subnet, $mask] = explode('/', $cidr);
$mask = (int) $mask;
if ($mask < 0 || $mask > 32) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
if ($ipLong === false || $subnetLong === false) {
return false;
}
$maskLong = -1 << (32 - $mask);
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
} catch (\Exception) {
return false;
}
}
public function refreshBlocklists(): void
{
foreach (array_keys(self::BLOCKLISTS) as $name) {
Cache::forget("cidr_blocklist_{$name}");
}
}
public function getBlocklistStats(): array
{
$totalCidrs = 0;
foreach (self::BLOCKLISTS as $name => $url) {
$cidrs = $this->getBlocklistCidrs($name, $url);
$totalCidrs += count($cidrs);
}
return [
'total' => $totalCidrs,
'source' => 'multi_blocklist',
];
}
}
+1358
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Services\Parsers;
class ExternalTextsParser
{
public function getBadgeData(string $badgeCode): array
{
// Minimal stub implementation for compatibility with PHPStan during migration.
return [
'image' => '',
'nitro' => [],
'flash' => [],
];
}
public function updateNitroBadgeTexts(string $code, string $title, string $description): void
{
// stub
}
public function updateFlashBadgeTexts(string $code, string $title, string $description): void
{
// stub
}
public function getBadgeImageUrl(string $badgeCode): string
{
return '';
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsitePermission;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
readonly class PermissionsService
{
private const string CACHE_KEY = 'website_permissions';
private const int CACHE_DURATION_MINUTES = 30;
private Collection $permissions;
public function __construct()
{
$data = Cache::remember(self::CACHE_KEY, now()->addMinutes(self::CACHE_DURATION_MINUTES), fn () => WebsitePermission::all()->pluck('min_rank', 'permission')->toArray());
$this->permissions = collect($data);
}
public function getOrDefault(string $permissionName, bool $default = false): bool
{
if ($this->permissions->isEmpty()) {
$permission = WebsitePermission::where('permission', $permissionName)->first();
if (! $permission) {
return $default;
}
return auth()->check() && auth()->user()->rank >= $permission->min_rank;
}
if (! $this->permissions->has($permissionName)) {
return $default;
}
$requiredRank = (int) $this->permissions->get($permissionName);
return auth()->check() && auth()->user()->rank >= $requiredRank;
}
public function all(): Collection
{
return $this->permissions;
}
public function flush(): void
{
Cache::forget(self::CACHE_KEY);
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsiteSetting;
use App\Models\RadioListenerPoint;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
readonly class PointsService
{
private const string POINTS_CACHE_KEY = 'user_points_';
private const string LEADERBOARD_CACHE_KEY = 'radio_leaderboard';
private const int LEADERBOARD_CACHE_DURATION = 300;
private const string SETTINGS_CACHE_KEY = 'points_service_settings';
private const int SETTINGS_CACHE_DURATION = 300;
private int $pointsPerMinute;
private int $maxPointsPerDay;
private int $requestPoints;
private int $votePoints;
private int $giveawayWinPoints;
private int $contestWinPoints;
public function __construct()
{
$settings = $this->getSettings();
$this->pointsPerMinute = (int) ($settings['points_per_minute'] ?? 1);
$this->maxPointsPerDay = (int) ($settings['max_points_per_day'] ?? 100);
$this->requestPoints = (int) ($settings['points_for_request'] ?? 5);
$this->votePoints = (int) ($settings['points_for_vote'] ?? 2);
$this->giveawayWinPoints = (int) ($settings['points_for_giveaway_win'] ?? 50);
$this->contestWinPoints = (int) ($settings['points_for_contest_win'] ?? 100);
}
public function awardPoints(User $user, int $points, string $reason): RadioListenerPoint
{
$pointRecord = RadioListenerPoint::awardPoints($user, $points, $reason);
$this->clearUserCache($user->id);
$this->clearLeaderboardCache();
return $pointRecord;
}
public function deductPoints(User $user, int $points, string $reason): RadioListenerPoint
{
$pointRecord = RadioListenerPoint::deductPoints($user, $points, $reason);
$this->clearUserCache($user->id);
$this->clearLeaderboardCache();
return $pointRecord;
}
public function getUserPoints(User $user): int
{
return Cache::remember(self::POINTS_CACHE_KEY . $user->id, 3600, fn (): int => (int) ($user->radio_points ?? 0));
}
/**
* @return array<int, array<string, mixed>>
*/
public function getUserHistory(User $user, int $limit = 50): array
{
return RadioListenerPoint::where('user_id', $user->id)
->orderBy('earned_at', 'desc')
->limit($limit)
->get()
->toArray();
}
/**
* @return array<int, array{rank: int, user_id: int, username: string, avatar: string|null, points: int}>
*/
public function getLeaderboard(int $limit = 100): array
{
return Cache::remember(self::LEADERBOARD_CACHE_KEY, self::LEADERBOARD_CACHE_DURATION, function () use ($limit): array {
/** @var Collection $collection */
$collection = User::where('radio_points', '>', 0)
->orderBy('radio_points', 'desc')
->limit($limit)
->get(['id', 'username', 'avatar', 'radio_points']);
return $collection->map(fn (User $user, int $index): array => [
'rank' => $index + 1,
'user_id' => $user->id,
'username' => $user->username,
'avatar' => $user->avatar ?? null,
'points' => (int) $user->radio_points,
])->toArray();
});
}
public function getWeeklyLeaderboard(int $limit = 50): array
{
return Cache::remember(self::LEADERBOARD_CACHE_KEY . '_weekly', self::LEADERBOARD_CACHE_DURATION, function () use ($limit) {
$weeklyPoints = RadioListenerPoint::where('earned_at', '>=', now()->subWeek())
->selectRaw('user_id, SUM(points) as total_points')
->groupBy('user_id')
->orderBy('total_points', 'desc')
->limit($limit)
->get();
$userIds = $weeklyPoints->pluck('user_id')->unique()->values();
$users = User::whereIn('id', $userIds)
->get(['id', 'username', 'avatar'])
->keyBy('id');
return $weeklyPoints->map(fn (object $item, int $index): array => [
'rank' => $index + 1,
'user_id' => $item->user_id,
'username' => $users[$item->user_id]?->username ?? 'Unknown',
'avatar' => $users[$item->user_id]?->avatar ?? null,
'points' => $item->total_points,
])->toArray();
});
}
public function getMonthlyLeaderboard(int $limit = 50): array
{
return Cache::remember(self::LEADERBOARD_CACHE_KEY . '_monthly', self::LEADERBOARD_CACHE_DURATION, function () use ($limit) {
$monthlyPoints = RadioListenerPoint::where('earned_at', '>=', now()->subMonth())
->selectRaw('user_id, SUM(points) as total_points')
->groupBy('user_id')
->orderBy('total_points', 'desc')
->limit($limit)
->get();
$userIds = $monthlyPoints->pluck('user_id')->unique()->values();
$users = User::whereIn('id', $userIds)
->get(['id', 'username', 'avatar'])
->keyBy('id');
return $monthlyPoints->map(fn (object $item, int $index): array => [
'rank' => $index + 1,
'user_id' => $item->user_id,
'username' => $users[$item->user_id]?->username ?? 'Unknown',
'avatar' => $users[$item->user_id]?->avatar ?? null,
'points' => $item->total_points,
])->toArray();
});
}
public function getUserRank(User $user): int
{
/** @var int $count */
$count = User::query()
->where('radio_points', '>', $user->radio_points ?? 0)
->count();
return $count + 1;
}
public function awardListeningPoints(User $user, int $minutes): int
{
$todayPoints = RadioListenerPoint::where('user_id', $user->id)
->where('earned_at', '>=', now()->startOfDay())
->sum('points');
$availablePoints = max(0, $this->maxPointsPerDay - $todayPoints);
$pointsToAward = min($minutes * $this->pointsPerMinute, $availablePoints);
if ($pointsToAward > 0) {
$this->awardPoints($user, $pointsToAward, 'Luisteren');
}
return $pointsToAward;
}
public function awardRequestPoints(User $user): int
{
$this->awardPoints($user, $this->requestPoints, 'Song Request');
return $this->requestPoints;
}
public function awardVotePoints(User $user): int
{
$this->awardPoints($user, $this->votePoints, 'Stemmen op Request');
return $this->votePoints;
}
public function awardWinPoints(User $user, string $type): int
{
$winPoints = match ($type) {
'giveaway' => $this->giveawayWinPoints,
'contest' => $this->contestWinPoints,
default => 10,
};
$this->awardPoints($user, $winPoints, 'Winst: ' . $type);
return $winPoints;
}
private function clearUserCache(int $userId): void
{
Cache::forget(self::POINTS_CACHE_KEY . $userId);
}
public function clearLeaderboardCache(): void
{
Cache::forget(self::LEADERBOARD_CACHE_KEY);
Cache::forget(self::LEADERBOARD_CACHE_KEY . '_weekly');
Cache::forget(self::LEADERBOARD_CACHE_KEY . '_monthly');
}
public function clearSettingsCache(): void
{
Cache::forget(self::SETTINGS_CACHE_KEY);
CacheService::clearPointsSettings();
}
public function getStats(): array
{
$totalPoints = RadioListenerPoint::sum('points');
$totalUsers = User::where('radio_points', '>', 0)->count();
return [
'total_points_awarded' => $totalPoints,
'total_active_users' => $totalUsers,
'points_per_minute' => $this->pointsPerMinute,
'max_points_per_day' => $this->maxPointsPerDay,
];
}
/**
* @return array<string, string|null>
*/
private function getSettings(): array
{
return Cache::remember(self::SETTINGS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::whereIn('key', [
'points_per_minute',
'max_points_per_day',
'points_for_request',
'points_for_vote',
'points_for_giveaway_win',
'points_for_contest_win',
])->pluck('value', 'key')->toArray());
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
namespace App\Services;
use App\Actions\SendCurrency;
use App\Actions\SendFurniture;
use App\Models\Shop\WebsiteShopArticle;
use App\Models\User;
class PurchaseService
{
public function __construct(
private readonly RconService $rconService,
private readonly SendCurrency $sendCurrency,
) {}
public function processPurchase(User $currentUser, WebsiteShopArticle $package, ?User $recipient = null): string
{
$user = $recipient ?? $currentUser;
$this->deductBalance($currentUser, $package);
$this->deliverCurrencies($user, $package);
$this->deliverRank($user, $package);
$this->deliverBadges($user, $package);
$this->deliverFurniture($currentUser, $package);
return $this->buildSuccessMessage($currentUser, $user, $package);
}
private function deductBalance(User $user, WebsiteShopArticle $package): void
{
$user->decrement('website_balance', $package->price());
}
private function deliverCurrencies(User $user, WebsiteShopArticle $package): void
{
$this->sendCurrency->execute($user, 'credits', $package->credits);
$this->sendCurrency->execute($user, 'duckets', $package->duckets);
$this->sendCurrency->execute($user, 'diamonds', $package->diamonds);
}
private function deliverRank(User $user, WebsiteShopArticle $package): void
{
if (! $package->give_rank) {
return;
}
if ($this->rconService->isConnected) {
$this->rconService->setRank($user, $package->give_rank);
$this->rconService->disconnectUser($user);
} else {
$user->update(['rank' => $package->give_rank]);
}
}
private function deliverBadges(User $user, WebsiteShopArticle $package): void
{
if (! $package->badges) {
return;
}
$badgeList = explode(';', $package->badges);
$ownedBadges = $user->badges()->pluck('badge_code')->toArray();
foreach ($badgeList as $badge) {
if (in_array($badge, $ownedBadges)) {
continue;
}
if ($this->rconService->isConnected) {
$this->rconService->giveBadge($user, $badge);
} else {
$user->badges()->updateOrCreate([
'user_id' => $user->id,
'badge_code' => $badge,
]);
}
}
}
private function deliverFurniture(User $currentUser, WebsiteShopArticle $package): void
{
if (! $package->furniture) {
return;
}
$sendFurniture = app(SendFurniture::class);
$sendFurniture->execute($currentUser, json_decode($package->furniture, true));
}
private function buildSuccessMessage(User $currentUser, User $recipient, WebsiteShopArticle $package): string
{
$message = __('You have successfully purchased the package :name', ['name' => $package->name]);
if ($recipient->username !== $currentUser->username) {
$message = __('You have successfully purchased the package :name for :username', [
'name' => $package->name,
'username' => $recipient->username,
]);
}
return $message;
}
}
+276
View File
@@ -0,0 +1,276 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\CurrencyTypes;
use App\Exceptions\RconConnectionException;
use Illuminate\Support\Facades\Log;
use JsonException;
use Socket;
class RconService
{
private ?Socket $socket = null;
public bool $isConnected = false;
/**
* @var array<string, mixed>
*/
private array $config = [];
public function __construct()
{
$this->config = [
'ip' => setting('rcon_ip'),
'port' => (int) setting('rcon_port'),
];
$this->initialize();
}
private function initialize(): void
{
$this->socket = @socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
if ($this->socket === false) {
$error = socket_strerror(socket_last_error());
Log::error("RCON initialization failed: {$error}");
$this->closeConnection();
return;
}
if (! @socket_connect($this->socket, $this->config['ip'], $this->config['port'])) {
$error = socket_strerror(socket_last_error());
Log::error("RCON connection failed: {$error}");
$this->closeConnection();
return;
}
$this->isConnected = true;
}
private function closeConnection(): void
{
if ($this->socket instanceof Socket) {
socket_close($this->socket);
}
$this->socket = null;
$this->isConnected = false;
}
public function isConnected(): bool
{
return $this->isConnected;
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function sendCommand(string $command, ?array $data = null): bool
{
if (! $this->isConnected) {
Log::error('RCON command failed: Not connected');
$this->closeConnection();
return false;
}
$payload = json_encode(['key' => $command, 'data' => $data], JSON_THROW_ON_ERROR);
if (! @socket_write($this->socket, $payload, strlen($payload))) {
$error = socket_strerror(socket_last_error($this->socket));
Log::error("RCON command ({$command}) failed: {$error}");
$this->closeConnection();
return false;
}
return true;
}
/**
* @throws RconConnectionException|JsonException
*/
public function sendGift(object $user, int $itemId, string $message = 'Here is a gift.'): void
{
$this->sendCommand('sendgift', [
'user_id' => $user->id,
'itemid' => $itemId,
'message' => $message,
]);
}
/**
* @throws RconConnectionException|JsonException
*/
public function giveCredits(object $user, int $credits): void
{
$this->sendCommand('givecredits', [
'user_id' => $user->id,
'credits' => $credits,
]);
}
/**
* @throws RconConnectionException|JsonException
*/
public function giveBadge(object $user, string $badge): void
{
$this->sendCommand('givebadge', [
'user_id' => $user->id,
'badge' => $badge,
]);
}
/**
* @throws RconConnectionException|JsonException
*/
public function setMotto(object $user, string $motto): void
{
$this->sendCommand('setmotto', [
'user_id' => $user->id,
'motto' => $motto,
]);
}
/**
* @throws RconConnectionException|JsonException
*/
public function updateWordFilter(): void
{
$this->sendCommand('updatewordfilter');
}
/**
* @throws RconConnectionException|JsonException
*/
public function disconnectUser(object $user): void
{
$this->sendCommand('disconnect', [
'user_id' => $user->id,
'username' => $user->username,
]);
}
/**
* @throws RconConnectionException|JsonException
*/
public function givePoints(object $user, CurrencyTypes $type, int $amount): void
{
$this->sendCommand('givepoints', [
'user_id' => $user->id,
'points' => $amount,
'type' => $type,
]);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function giveGotw(object $user, int $amount): void
{
$this->givePoints($user, CurrencyTypes::Points, $amount);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function giveDiamonds(object $user, int $amount): void
{
$this->givePoints($user, CurrencyTypes::Diamonds, $amount);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function giveDuckets(object $user, int $amount): void
{
$this->givePoints($user, CurrencyTypes::Duckets, $amount);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function setRank(object $user, int $rank): void
{
$this->sendCommand('setrank', [
'user_id' => $user->id,
'rank' => $rank,
]);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function updateCatalog(): void
{
$this->sendCommand('updatecatalog');
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function alertUser(object $user, string $message): void
{
$this->sendCommand('alertuser', [
'user_id' => $user->id,
'message' => $message,
]);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function forwardUser(object $user, int $roomId): void
{
$this->sendCommand('forwarduser', [
'user_id' => $user->id,
'room_id' => $roomId,
]);
}
/**
* @throws RconConnectionException
* @throws JsonException
*/
public function updateConfig(object $user, string $command): void
{
$this->sendCommand('executecommand', [
'user_id' => $user->id,
'command' => $command,
]);
}
/**
* Send an RCON command safely from dashboard with error handling
*
* @param array<string, mixed> $params The parameters for the command
*
* @throws RconConnectionException
* @throws JsonException
*/
public function sendSafelyFromDashboard(string $command, array $params, string $errorMessage): void
{
if (! $this->isConnected()) {
Log::error($errorMessage . ' - RCON not connected');
return;
}
$this->sendCommand($command, $params);
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsiteLanguage;
use App\Models\Miscellaneous\WebsiteSetting;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Throwable;
class SettingsService
{
private const string CACHE_KEY = 'website_settings';
private const string LANGUAGES_CACHE_KEY = 'website_languages';
private ?Collection $cachedSettings = null;
public function __construct(
private readonly InstallationService $installationService,
) {}
public function getOrDefault(string $key, mixed $default = null): mixed
{
$settings = $this->settings();
return $settings->get($key, $default);
}
public function getLanguages(): Collection
{
return Cache::rememberForever(self::LANGUAGES_CACHE_KEY, function (): Collection {
try {
if (! Schema::hasTable('website_languages')) {
return collect();
}
return WebsiteLanguage::all();
} catch (Throwable) {
return collect();
}
});
}
public static function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
Cache::forget(self::LANGUAGES_CACHE_KEY);
}
public function clearInstanceCache(): void
{
$this->cachedSettings = null;
Cache::forget(self::CACHE_KEY);
Cache::forget(self::LANGUAGES_CACHE_KEY);
}
public function set(string $key, mixed $value): void
{
WebsiteSetting::updateOrCreate(
['key' => $key],
['value' => is_bool($value) ? ($value ? '1' : '0') : (string) $value],
);
// Clear cache to ensure fresh values are fetched
self::clearCache();
}
private function settings(): Collection
{
if ($this->isInstallationIncomplete()) {
return $this->fetchSettings();
}
$this->cachedSettings = collect(Cache::rememberForever(self::CACHE_KEY, fn () => $this->fetchSettings()->toArray()));
return $this->cachedSettings;
}
private function isInstallationIncomplete(): bool
{
try {
return ! $this->installationService->isComplete();
} catch (Throwable) {
return true;
}
}
private function fetchSettings(): Collection
{
try {
if (! Schema::hasTable('website_settings')) {
return collect();
}
return WebsiteSetting::query()->pluck('value', 'key');
} catch (Throwable) {
return collect();
}
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Miscellaneous\WebsiteSetting;
use Carbon\Carbon;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
readonly class StreamMonitoringService
{
private const string CACHE_KEY = 'radio_stream_status';
private const int CACHE_DURATION_MINUTES = 1;
private const string SETTINGS_CACHE_KEY = 'radio_monitor_settings';
private const int SETTINGS_CACHE_DURATION = 60;
private string $streamUrl;
private int $timeout;
public function __construct()
{
$settings = $this->getSettings();
$this->streamUrl = $settings['radio_stream_url'] ?? '';
$this->timeout = (int) ($settings['radio_monitoring_timeout'] ?? 5);
}
public function getStatus(): array
{
return Cache::remember(self::CACHE_KEY, now()->addMinutes(self::CACHE_DURATION_MINUTES), fn (): array => $this->fetchStatus());
}
private function fetchStatus(): array
{
$status = [
'online' => false,
'listeners' => 0,
'bitrate' => 0,
'format' => null,
'server_type' => null,
'last_check' => now()->toIso8601String(),
'error' => null,
];
if ($this->streamUrl === '') {
$status['error'] = 'No stream URL configured';
return $status;
}
try {
$response = Http::timeout($this->timeout)
->withOptions([
'verify' => false,
'allow_redirects' => true,
])
->head($this->streamUrl);
if ($response->successful()) {
$status['online'] = true;
$status['listeners'] = $this->extractListeners($response);
$status['bitrate'] = $this->extractBitrate($response);
$status['format'] = $this->extractFormat($response);
$status['server_type'] = $this->detectServerType($response);
} else {
$status['error'] = 'Stream returned status: ' . $response->status();
}
} catch (\Exception $e) {
$status['error'] = $e->getMessage();
}
return $status;
}
private function extractListeners(Response $response): int
{
$headers = [
'x-listeners',
'x-audiocast-listeners',
'icecast-listeners',
'listeners',
];
foreach ($headers as $header) {
$value = $response->header($header);
if ($value !== null && is_numeric($value)) {
return (int) $value;
}
}
return 0;
}
private function extractBitrate(Response $response): int
{
$headers = ['x-bitrate', 'bitrate'];
foreach ($headers as $header) {
$value = $response->header($header);
if ($value !== null && is_numeric($value)) {
return (int) $value;
}
}
return 0;
}
private function extractFormat(Response $response): ?string
{
$contentType = $response->header('content-type');
if ($contentType === null) {
return null;
}
return match (true) {
str_contains($contentType, 'mp3') => 'MP3',
str_contains($contentType, 'ogg') => 'OGG',
str_contains($contentType, 'aac') => 'AAC',
default => null,
};
}
private function detectServerType(Response $response): ?string
{
$server = $response->header('server');
if ($server !== null) {
$serverLower = strtolower($server);
return match (true) {
str_contains($serverLower, 'icecast') => 'Icecast',
str_contains($serverLower, 'shoutcast') => 'Shoutcast',
str_contains($serverLower, 'azurecast') => 'AzureCast',
default => null,
};
}
$headers = $response->headers();
return isset($headers['x-audiocast-server']) ? 'AzureCast' : null;
}
public function getUptime(): array
{
$uptime = $this->getSetting('radio_last_online');
if ($uptime === null) {
return [
'was_ever_online' => false,
'last_online' => null,
'uptime_percentage' => 0,
];
}
$lastOnline = Carbon::parse($uptime);
$now = now();
$diff = $lastOnline->diff($now);
$totalMinutes = $diff->days * 24 * 60 + $diff->h * 60 + $diff->i;
return [
'was_ever_online' => true,
'last_online' => $lastOnline->toIso8601String(),
'downtime_minutes' => $totalMinutes,
'last_online_human' => $lastOnline->diffForHumans(),
];
}
public function recordOnlineStatus(bool $isOnline): void
{
if ($isOnline) {
WebsiteSetting::updateOrCreate(
['key' => 'radio_last_online'],
['value' => now()->toIso8601String()],
);
$this->incrementStat('radio_total_online_minutes');
}
$this->incrementStat('radio_total_checks');
}
private function incrementStat(string $key): void
{
$current = (int) ($this->getSetting($key) ?? 0);
WebsiteSetting::updateOrCreate(
['key' => $key],
['value' => (string) ($current + 1)],
);
}
public function getStats(): array
{
$totalChecks = (int) ($this->getSetting('radio_total_checks') ?? 0);
$totalOnlineMinutes = (int) ($this->getSetting('radio_total_online_minutes') ?? 0);
$uptimePercentage = $totalChecks > 0 ? round(($totalOnlineMinutes / max($totalChecks, 1)) * 100, 2) : 0;
return [
'current_status' => $this->getStatus(),
'uptime' => $this->getUptime(),
'total_checks' => $totalChecks,
'total_online_minutes' => $totalOnlineMinutes,
'uptime_percentage' => $uptimePercentage,
];
}
private function getSetting(string $key): ?string
{
return $this->getSettings()[$key] ?? null;
}
/**
* @return array<string, string|null>
*/
private function getSettings(): array
{
return Cache::remember(self::SETTINGS_CACHE_KEY, self::SETTINGS_CACHE_DURATION, fn (): array => WebsiteSetting::whereIn('key', [
'radio_stream_url',
'radio_monitoring_timeout',
'radio_last_online',
'radio_total_checks',
'radio_total_online_minutes',
])->pluck('value', 'key')->toArray());
}
}
+596
View File
@@ -0,0 +1,596 @@
<?php
namespace App\Services;
class SystemFixService
{
private array $results = [];
public function checkAndFixAll(): array
{
$this->results = [];
// 1. Check PHP extensions
$this->checkAndFixPhpExtensions();
// 2. Check Git
$this->checkAndFixGit();
// 3. Check Maven
$this->checkAndFixMaven();
// 4. Check Node.js
$this->checkAndFixNode();
// 5. Check MySQL connection
$this->checkDatabase();
// 6. Fix permissions
$this->fixPermissions();
// 7. Fix open_basedir
$this->fixOpenBaseDir();
// 8. Configure emulator database from .env
$this->configureEmulatorDatabase();
// 9. Create missing directories
$this->createMissingDirectories();
// 10. Fix storage permissions
$this->fixStoragePermissions();
// 11. Check disk space
$this->checkDiskSpace();
// 12. Clear old caches
$this->clearOldCaches();
// 13. Fix git safe directories
$this->fixGitSafeDirectories();
// 14. Create .m2 directory for Maven
$this->fixMavenDirectory();
// 15. Check and install Java (needed for Maven)
$this->checkAndFixJava();
return $this->results;
}
private function checkAndFixPhpExtensions(): void
{
$requiredExtensions = ['pdo_mysql', 'curl', 'json', 'mbstring', 'xml', 'zip', 'bcmath'];
$missing = [];
foreach ($requiredExtensions as $ext) {
if (! extension_loaded($ext)) {
$missing[] = $ext;
}
}
if ($missing === []) {
$this->results[] = ['item' => 'PHP Extensions', 'status' => 'ok', 'message' => 'Alle vereiste extensies geïnstalleerd'];
} else {
$this->results[] = ['item' => 'PHP Extensions', 'status' => 'warning', 'message' => 'Ontbrekend: ' . implode(', ', $missing)];
}
}
private function checkAndFixGit(): void
{
$result = shell_exec('which git 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$this->results[] = ['item' => 'Git', 'status' => 'ok', 'message' => trim($result)];
} else {
// Install Git
shell_exec('sudo apt-get update && sudo apt-get install -y git 2>&1');
$result = shell_exec('which git 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$this->results[] = ['item' => 'Git', 'status' => 'fixed', 'message' => 'Automatisch geïnstalleerd'];
} else {
$this->results[] = ['item' => 'Git', 'status' => 'error', 'message' => 'Installatie mislukt'];
}
}
}
private function checkAndFixMaven(): void
{
$result = shell_exec('which mvn 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$this->results[] = ['item' => 'Maven', 'status' => 'ok', 'message' => trim($result)];
} else {
// Install Maven
shell_exec('sudo apt-get update && sudo apt-get install -y maven 2>&1');
// Fix Maven repository permissions
shell_exec('sudo mkdir -p /var/www/.m2/repository && sudo chown -R www-data:www-data /var/www/.m2');
$result = shell_exec('which mvn 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$this->results[] = ['item' => 'Maven', 'status' => 'fixed', 'message' => 'Automatisch geïnstalleerd'];
} else {
$this->results[] = ['item' => 'Maven', 'status' => 'error', 'message' => 'Installatie mislukt'];
}
}
}
private function checkAndFixNode(): void
{
// Use shell command to check for node (bypasses open_basedir)
$nodeResult = shell_exec('which node 2>/dev/null || echo ""');
$nodePath = trim($nodeResult ?? '');
if ($nodePath !== '' && $nodePath !== '0') {
$version = shell_exec("{$nodePath} --version 2>/dev/null || echo ''");
$versionNum = trim($version ?? '');
// Check if version is 24.x
if (str_starts_with($versionNum, 'v24')) {
$this->results[] = ['item' => 'Node.js', 'status' => 'ok', 'message' => $versionNum];
} else {
// Upgrade to Node.js 24
$this->installNode24();
}
} else {
// Install Node.js 24
$this->installNode24();
}
}
private function installNode24(): void
{
// Install Node.js 24 via NodeSource
$commands = [
'curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash - 2>&1',
'sudo apt-get install -y nodejs 2>&1',
];
$success = true;
foreach ($commands as $cmd) {
$result = shell_exec($cmd);
if (str_contains($result ?? '', 'E:')) {
$success = false;
break;
}
}
// Verify installation
$version = shell_exec('node --version 2>/dev/null || echo ""');
$versionNum = trim($version ?? '');
if ($versionNum !== '' && $versionNum !== '0' && str_starts_with($versionNum, 'v24')) {
$this->results[] = ['item' => 'Node.js', 'status' => 'fixed', 'message' => "Geïnstalleerd: {$versionNum}"];
} else {
$this->results[] = ['item' => 'Node.js', 'status' => 'error', 'message' => 'Installatie mislukt'];
}
}
private function checkDatabase(): void
{
try {
$host = setting('emulator_database_host', '127.0.0.1');
$port = setting('emulator_database_port', '3306');
$name = setting('emulator_database_name', config('database.connections.mysql.database', ''));
$username = setting('emulator_database_username', config('database.connections.mysql.username', ''));
$password = setting('emulator_database_password', config('database.connections.mysql.password', ''));
if (empty($name) || empty($username)) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'warning', 'message' => 'Niet geconfigureerd'];
$this->autoFixEmulatorDatabase();
return;
}
// Validate inputs
$validationErrors = $this->validateDatabaseInputs($host, $port, $name, $username);
if ($validationErrors !== []) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => implode(', ', $validationErrors)];
$this->autoFixEmulatorDatabase();
return;
}
// Try connection
try {
$pdo = new \PDO(
"mysql:host={$host};port={$port};charset=utf8mb4",
$username,
$password,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 5],
);
// Test with database
$pdo->exec("USE `{$name}`");
$pdo->query('SELECT 1');
$this->results[] = ['item' => 'Emulator Database', 'status' => 'ok', 'message' => "Verbonden met {$name}"];
} catch (\PDOException $e) {
$errorMsg = $e->getMessage();
if (str_contains($errorMsg, 'Unknown database')) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => "Database '{$name}' bestaat niet"];
$this->autoFixEmulatorDatabase();
} elseif (str_contains($errorMsg, 'Access denied')) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => 'Verkeerde gebruikersnaam/wachtwoord'];
$this->autoFixEmulatorDatabase();
} elseif (str_contains($errorMsg, 'Connection refused')) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => "Kan niet verbinden met {$host}:{$port}"];
} else {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => $errorMsg];
}
}
} catch (\Exception $e) {
$this->results[] = ['item' => 'Emulator Database', 'status' => 'error', 'message' => $e->getMessage()];
$this->autoFixEmulatorDatabase();
}
}
private function validateDatabaseInputs(string $host, string $port, string $name, string $username): array
{
$errors = [];
// Validate host
if ($host === '' || $host === '0') {
$errors[] = 'Host is leeg';
} elseif (! filter_var($host, FILTER_VALIDATE_IP) && ! preg_match('/^[a-zA-Z0-9.-]+$/', $host)) {
$errors[] = 'Ongeldige host: ' . $host;
}
// Validate port
if ($port === '' || $port === '0') {
$errors[] = 'Port is leeg';
} elseif (! is_numeric($port) || $port < 1 || $port > 65535) {
$errors[] = 'Ongeldige port: ' . $port;
}
// Validate database name
if ($name === '' || $name === '0') {
$errors[] = 'Database naam is leeg';
} elseif (! preg_match('/^\w+$/', $name)) {
$errors[] = 'Ongeldige database naam: ' . $name;
}
// Validate username
if ($username === '' || $username === '0') {
$errors[] = 'Gebruikersnaam is leeg';
} elseif (! preg_match('/^\w+$/', $username)) {
$errors[] = 'Ongeldige gebruikersnaam: ' . $username;
}
return $errors;
}
private function autoFixEmulatorDatabase(): void
{
// Try to get correct values from .env / config
$host = config('database.connections.mysql.host', '127.0.0.1');
$port = config('database.connections.mysql.port', '3306');
$name = config('database.connections.mysql.database', '');
$username = config('database.connections.mysql.username', '');
$password = config('database.connections.mysql.password', '');
if (empty($name) || empty($username)) {
// Try to detect from MySQL process list or common names
$detected = $this->detectDatabaseFromMysql();
if ($detected) {
$name = $detected['name'];
$host = $detected['host'] ?? $host;
$username = $detected['username'] ?? $username;
$password = $detected['password'] ?? $password;
} else {
$this->results[] = ['item' => 'Emulator Database Fix', 'status' => 'warning', 'message' => 'Kon geen geldige database vinden'];
return;
}
}
$settings = app(SettingsService::class);
$settings->set('emulator_database_host', $host);
$settings->set('emulator_database_port', (string) $port);
$settings->set('emulator_database_name', $name);
$settings->set('emulator_database_username', $username);
$settings->set('emulator_database_password', $password);
$this->results[] = ['item' => 'Emulator Database Fix', 'status' => 'fixed', 'message' => "Automatisch gefixt: {$name}@{$host}"];
}
private function detectDatabaseFromMysql(): ?array
{
// Common database names for hotel emulators
$commonNames = ['habbo', 'hotel', 'retro', 'emulator', 'game', 'server', 'arcturus', 'fusion'];
// Get CMS database credentials that work
$cmsHost = config('database.connections.mysql.host', '127.0.0.1');
$cmsPort = config('database.connections.mysql.port', '3306');
$cmsUser = config('database.connections.mysql.username', '');
$cmsPass = config('database.connections.mysql.password', '');
$cmsDb = config('database.connections.mysql.database', '');
if (empty($cmsUser)) {
return null;
}
try {
$pdo = new \PDO(
"mysql:host={$cmsHost};port={$cmsPort};charset=utf8mb4",
$cmsUser,
$cmsPass,
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 5],
);
// Get all databases the user can access
$stmt = $pdo->query('SHOW DATABASES');
$databases = $stmt->fetchAll(\PDO::FETCH_COLUMN);
// Check for common names
foreach ($commonNames as $name) {
if (in_array($name, $databases)) {
return [
'name' => $name,
'host' => $cmsHost,
'username' => $cmsUser,
'password' => $cmsPass,
];
}
}
// If CMS database exists and works, use that
if (! empty($cmsDb) && in_array($cmsDb, $databases)) {
return [
'name' => $cmsDb,
'host' => $cmsHost,
'username' => $cmsUser,
'password' => $cmsPass,
];
}
// Return first database that's not system db
foreach ($databases as $db) {
if (! in_array($db, ['information_schema', 'performance_schema', 'mysql', 'sys'])) {
return [
'name' => $db,
'host' => $cmsHost,
'username' => $cmsUser,
'password' => $cmsPass,
];
}
}
} catch (\Exception) {
// Could not detect
}
return null;
}
private function fixPermissions(): void
{
$directories = [
'/var/www/emulator-source',
'/var/www/nitro-client',
'/var/www/nitro-renderer',
'/var/www/Client',
'/var/www/atomcms/storage',
'/var/www/atomcms/bootstrap/cache',
'/var/www/atomcms/app',
];
$fixed = false;
foreach ($directories as $dir) {
if (is_dir($dir)) {
// Check if already owned by www-data
$owner = shell_exec("stat -c '%U' {$dir} 2>/dev/null || echo ''");
if (trim($owner ?? '') !== 'www-data') {
shell_exec("sudo chown -R www-data:www-data {$dir} 2>/dev/null || true");
$fixed = true;
}
}
}
// Always fix app directory permissions (new files might have wrong owner)
shell_exec("sudo find /var/www/atomcms/app -type f -name '*.php' ! -user www-data -exec chown www-data:www-data {} \\; 2>/dev/null || true");
$this->results[] = ['item' => 'Permissions', 'status' => $fixed ? 'fixed' : 'ok', 'message' => $fixed ? 'Directories eigendom gefixt' : 'Al correct geconfigureerd'];
}
private function fixOpenBaseDir(): void
{
$openBaseDir = ini_get('open_basedir');
if (in_array($openBaseDir, ['', '0', false], true)) {
$this->results[] = ['item' => 'open_basedir', 'status' => 'ok', 'message' => 'Geen restrictie'];
return;
}
// Check if all required paths are in open_basedir
$requiredPaths = ['/var/www', '/tmp', '/root'];
$missing = [];
foreach ($requiredPaths as $path) {
if (! str_contains($openBaseDir, $path)) {
$missing[] = $path;
}
}
if ($missing === []) {
$this->results[] = ['item' => 'open_basedir', 'status' => 'ok', 'message' => 'Alle paden toegestaan'];
} else {
$this->results[] = ['item' => 'open_basedir', 'status' => 'warning', 'message' => 'Ontbrekende paden: ' . implode(', ', $missing)];
}
}
private function configureEmulatorDatabase(): void
{
// Only configure if not already set
$currentHost = setting('emulator_database_host', '');
if (! empty($currentHost)) {
$this->results[] = ['item' => 'Emulator Database Config', 'status' => 'ok', 'message' => 'Al geconfigureerd'];
return;
}
// Get from .env
$host = config('database.connections.mysql.host', '127.0.0.1');
$port = config('database.connections.mysql.port', '3306');
$name = config('database.connections.mysql.database', '');
$username = config('database.connections.mysql.username', '');
$password = config('database.connections.mysql.password', '');
if (empty($name) || empty($username)) {
$this->results[] = ['item' => 'Emulator Database Config', 'status' => 'warning', 'message' => 'Geen database gevonden in .env'];
return;
}
$settings = app(SettingsService::class);
$settings->set('emulator_database_host', $host);
$settings->set('emulator_database_port', $port);
$settings->set('emulator_database_name', $name);
$settings->set('emulator_database_username', $username);
$settings->set('emulator_database_password', $password);
$this->results[] = ['item' => 'Emulator Database Config', 'status' => 'fixed', 'message' => "Automatisch geconfigureerd: {$name}@{$host}"];
}
private function createMissingDirectories(): void
{
$directories = [
'/var/www/emulator-source',
'/var/www/nitro-client',
'/var/www/nitro-client/dist',
'/var/www/nitro-renderer',
'/var/www/Client',
'/var/www/Client/gamedata',
'/var/www/Gamedata',
'/root/emulator',
];
$created = [];
foreach ($directories as $dir) {
if (! is_dir($dir)) {
shell_exec("sudo mkdir -p {$dir} 2>/dev/null && sudo chown www-data:www-data {$dir} 2>/dev/null");
$created[] = basename($dir);
}
}
if ($created !== []) {
$this->results[] = ['item' => 'Directories', 'status' => 'fixed', 'message' => 'Aangemaakt: ' . implode(', ', $created)];
} else {
$this->results[] = ['item' => 'Directories', 'status' => 'ok', 'message' => 'Alle directories bestaan'];
}
}
private function fixStoragePermissions(): void
{
$storageDirs = [
'/var/www/atomcms/storage',
'/var/www/atomcms/storage/logs',
'/var/www/atomcms/storage/framework',
'/var/www/atomcms/storage/framework/cache',
'/var/www/atomcms/storage/framework/sessions',
'/var/www/atomcms/storage/framework/views',
'/var/www/atomcms/bootstrap/cache',
];
$fixed = false;
foreach ($storageDirs as $dir) {
if (is_dir($dir)) {
$owner = shell_exec("stat -c '%U' {$dir} 2>/dev/null || echo ''");
if (trim($owner ?? '') !== 'www-data') {
shell_exec("sudo chown -R www-data:www-data {$dir} 2>/dev/null");
$fixed = true;
}
} else {
shell_exec("sudo mkdir -p {$dir} && sudo chown www-data:www-data {$dir} 2>/dev/null");
$fixed = true;
}
}
// Make storage writable
shell_exec('sudo chmod -R 775 /var/www/atomcms/storage 2>/dev/null');
shell_exec('sudo chmod -R 775 /var/www/atomcms/bootstrap/cache 2>/dev/null');
$this->results[] = ['item' => 'Storage Permissions', 'status' => $fixed ? 'fixed' : 'ok', 'message' => $fixed ? 'Gefixt' : 'Al correct'];
}
private function checkDiskSpace(): void
{
$result = shell_exec("df -h /var/www 2>/dev/null | tail -1 | awk '{print $5}'");
$usage = trim($result ?? '0%');
$usageNum = (int) str_replace('%', '', $usage);
if ($usageNum >= 90) {
$this->results[] = ['item' => 'Disk Space', 'status' => 'error', 'message' => "Disk {$usage} vol!"];
} elseif ($usageNum >= 75) {
$this->results[] = ['item' => 'Disk Space', 'status' => 'warning', 'message' => "Disk {$usage} gebruikt"];
} else {
$this->results[] = ['item' => 'Disk Space', 'status' => 'ok', 'message' => "Disk {$usage} gebruikt"];
}
}
private function clearOldCaches(): void
{
// Clear old temp files
shell_exec("find /tmp -name 'emulator-update-*' -mtime +1 -exec rm -rf {} \\; 2>/dev/null");
shell_exec("find /tmp -name 'nitro_*' -mtime +1 -exec rm -rf {} \\; 2>/dev/null");
shell_exec("find /tmp -name '*.lock' -mtime +1 -delete 2>/dev/null");
// Clear Laravel caches
shell_exec('cd /var/www/atomcms && php artisan cache:clear 2>/dev/null');
shell_exec('cd /var/www/atomcms && php artisan view:clear 2>/dev/null');
$this->results[] = ['item' => 'Cache Cleanup', 'status' => 'fixed', 'message' => 'Oude caches opgeruimd'];
}
private function fixGitSafeDirectories(): void
{
$directories = [
'/var/www/atomcms',
'/var/www/emulator-source',
'/var/www/emulator-source/Emulator',
'/var/www/nitro-client',
'/var/www/nitro-renderer',
'/var/www/Client',
'/root',
'/root/emulator',
'/var/www',
];
foreach ($directories as $dir) {
shell_exec("git config --global --add safe.directory {$dir} 2>/dev/null || true");
}
$this->results[] = ['item' => 'Git Safe Directories', 'status' => 'fixed', 'message' => 'Alle directories toegevoegd'];
}
private function fixMavenDirectory(): void
{
$mavenDir = '/var/www/.m2';
if (! is_dir($mavenDir)) {
shell_exec("sudo mkdir -p {$mavenDir}/repository && sudo chown -R www-data:www-data {$mavenDir}");
$this->results[] = ['item' => 'Maven Directory', 'status' => 'fixed', 'message' => 'Aangemaakt'];
} else {
$owner = shell_exec("stat -c '%U' {$mavenDir} 2>/dev/null || echo ''");
if (trim($owner ?? '') !== 'www-data') {
shell_exec("sudo chown -R www-data:www-data {$mavenDir}");
$this->results[] = ['item' => 'Maven Directory', 'status' => 'fixed', 'message' => 'Permissions gefixt'];
} else {
$this->results[] = ['item' => 'Maven Directory', 'status' => 'ok', 'message' => 'Al correct'];
}
}
}
private function checkAndFixJava(): void
{
$result = shell_exec('which java 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$version = shell_exec('java -version 2>&1 | head -1');
$this->results[] = ['item' => 'Java', 'status' => 'ok', 'message' => trim($version ?? '')];
} else {
// Install Java
shell_exec('sudo apt-get update && sudo apt-get install -y default-jdk 2>&1');
$result = shell_exec('which java 2>/dev/null || echo ""');
if (! in_array(trim($result ?? ''), ['', '0'], true)) {
$this->results[] = ['item' => 'Java', 'status' => 'fixed', 'message' => 'Automatisch geïnstalleerd'];
} else {
$this->results[] = ['item' => 'Java', 'status' => 'error', 'message' => 'Installatie mislukt'];
}
}
}
}
+210
View File
@@ -0,0 +1,210 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class TraxService
{
public function syncSoundSets(): array
{
Log::info('[TraxService] Starting sound sets sync');
$furnitureData = json_decode(
file_get_contents('/var/www/Gamedata/config/FurnitureData.json'),
true,
);
$inserted = 0;
$soundSets = [];
foreach ($furnitureData['roomitemtypes']['furnitype'] ?? [] as $item) {
$classname = $item['classname'] ?? '';
// Collect sound_set items
if (str_starts_with((string) $classname, 'sound_set')) {
$setId = (int) str_replace('sound_set_', '', $classname);
$soundSets[] = [
'item_id' => $item['id'],
'set_id' => $setId,
'name' => $item['name'] ?? $classname,
];
}
}
// Insert sound sets into trax_songs (if table exists)
if (DB::getSchemaBuilder()->hasTable('soundsets')) {
DB::table('soundsets')->truncate();
foreach ($soundSets as $set) {
DB::table('soundsets')->insert([
'id' => $set['set_id'],
'name' => $set['name'],
'trackid' => $set['item_id'],
]);
$inserted++;
}
}
Log::info('[TraxService] Sound sets sync complete', [
'inserted' => $inserted,
]);
return [
'inserted' => $inserted,
'total_sets' => count($soundSets),
];
}
public function syncSoundtracks(): array
{
Log::info('[TraxService] Starting soundtracks sync');
$soundFiles = glob('/var/www/Gamedata/sounds/sound_machine_sample_*.mp3');
$existing = DB::table('soundtracks')->pluck('id')->toArray();
$inserted = 0;
foreach ($soundFiles as $file) {
$basename = basename($file, '.mp3');
preg_match('/sound_machine_sample_(\d+)/', $basename, $matches);
if (isset($matches[1]) && ($matches[1] !== '' && $matches[1] !== '0')) {
$sampleId = (int) $matches[1];
if (! in_array($sampleId, $existing)) {
DB::table('soundtracks')->insert([
'id' => $sampleId,
'code' => $basename,
'name' => 'Sample ' . $sampleId,
'author' => 'System',
'track' => '',
'length' => 0,
]);
$inserted++;
}
}
}
Log::info('[TraxService] Soundtracks sync complete', [
'inserted' => $inserted,
]);
return [
'inserted' => $inserted,
'total' => DB::table('soundtracks')->count(),
'samples' => count($soundFiles),
];
}
public function getStats(): array
{
return [
'soundtracks' => DB::table('soundtracks')->count(),
'sound_samples' => count(glob('/var/www/Gamedata/sounds/sound_machine_sample_*.mp3') ?: []),
'room_trax' => DB::table('room_trax')->count(),
'trax_playlist' => DB::table('trax_playlist')->count(),
'soundsets' => $this->countSoundSets(),
];
}
private function countSoundSets(): int
{
$furnitureData = json_decode(
file_get_contents('/var/www/Gamedata/config/FurnitureData.json'),
true,
);
$count = 0;
foreach ($furnitureData['roomitemtypes']['furnitype'] ?? [] as $item) {
if (str_starts_with($item['classname'] ?? '', 'sound_set')) {
$count++;
}
}
return $count;
}
public function importFromUrl(string $baseUrl): array
{
Log::info('[TraxService] Importing sounds from: ' . $baseUrl);
$results = [
'downloaded' => 0,
'failed' => 0,
];
$soundPath = '/var/www/Gamedata/sounds';
$soundUrl = rtrim($baseUrl, '/') . '/gamedata/sounds';
// Get list of sound samples from FurnitureData
$furnitureData = json_decode(
file_get_contents('/var/www/Gamedata/config/FurnitureData.json'),
true,
);
$sampleIds = [];
foreach ($furnitureData['roomitemtypes']['furnitype'] ?? [] as $item) {
if (str_starts_with($item['classname'] ?? '', 'sound_set')) {
// Each sound set can have multiple samples
for ($i = 0; $i < 200; $i++) {
$sampleIds[] = $i;
}
}
}
$sampleIds = array_unique($sampleIds);
foreach ($sampleIds as $id) {
$filename = 'sound_machine_sample_' . $id . '.mp3';
$localFile = $soundPath . '/' . $filename;
if (! file_exists($localFile)) {
$url = $soundUrl . '/' . $filename;
$content = @file_get_contents($url);
if ($content && strlen($content) > 1000) {
file_put_contents($localFile, $content);
$results['downloaded']++;
} else {
$results['failed']++;
}
}
}
Log::info('[TraxService] Import complete', $results);
return $results;
}
public function getTraxItems(): array
{
return DB::table('items_base')
->where('item_name', 'like', '%sound%')
->orWhere('item_name', 'like', '%trax%')
->orWhere('interaction_type', '=', 'sound')
->get()
->toArray();
}
public function linkSoundMachine(): bool
{
// Make sure sound_machine has correct interaction type
$machine = DB::table('items_base')
->where('item_name', 'sound_machine')
->first();
if ($machine) {
DB::table('items_base')
->where('id', $machine->id)
->update(['interaction_type' => 'sound_machine']);
Log::info('[TraxService] Sound machine linked');
return true;
}
return false;
}
}
+186
View File
@@ -0,0 +1,186 @@
<?php
namespace App\Services;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class UpdateHistoryService
{
private const string TABLE = 'update_history';
public function ensureTableExists(): void
{
if (! Schema::hasTable(self::TABLE)) {
Schema::create(self::TABLE, function ($table) {
$table->id();
$table->string('type'); // nitro, emulator, sql, config
$table->string('action'); // update, build, deploy, fix
$table->string('item')->nullable(); // filename, version, etc
$table->string('status'); // success, failed, pending
$table->text('message')->nullable();
$table->string('user')->nullable();
$table->string('ip')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->index(['type', 'created_at']);
$table->index('status');
});
}
}
public function log(string $type, string $action, ?string $item = null, string $status = 'success', ?string $message = null): void
{
$this->ensureTableExists();
DB::table(self::TABLE)->insert([
'type' => $type,
'action' => $action,
'item' => $item,
'status' => $status,
'message' => $message,
'user' => auth()->user()?->name ?? 'System',
'ip' => request()->ip(),
'created_at' => now(),
]);
}
public function getRecent(int $limit = 50): array
{
$this->ensureTableExists();
return DB::table(self::TABLE)
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->toArray();
}
public function getByType(string $type, int $limit = 50): array
{
$this->ensureTableExists();
return DB::table(self::TABLE)
->where('type', $type)
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->toArray();
}
public function getByStatus(string $status, int $limit = 50): array
{
$this->ensureTableExists();
return DB::table(self::TABLE)
->where('status', $status)
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->toArray();
}
public function getStats(): array
{
$this->ensureTableExists();
$total = DB::table(self::TABLE)->count();
$success = DB::table(self::TABLE)->where('status', 'success')->count();
$failed = DB::table(self::TABLE)->where('status', 'failed')->count();
$lastUpdate = DB::table(self::TABLE)
->orderBy('created_at', 'desc')
->first();
$byType = DB::table(self::TABLE)
->select('type', DB::raw('count(*) as count'))
->groupBy('type')
->pluck('count', 'type')
->toArray();
return [
'total' => $total,
'success' => $success,
'failed' => $failed,
'success_rate' => $total > 0 ? round(($success / $total) * 100) : 100,
'last_update' => $lastUpdate,
'by_type' => $byType,
];
}
public function getHtml(): string
{
$this->ensureTableExists();
$updates = $this->getRecent(20);
$stats = $this->getStats();
$html = '<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif;">';
// Stats
$html .= '<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;">';
$html .= '<div style="background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; text-align: center;">';
$html .= '<div style="font-size: 24px; font-weight: 700; color: #4ade80;">' . $stats['total'] . '</div>';
$html .= '<div style="font-size: 10px; color: #64748b; text-transform: uppercase;">Totaal</div></div>';
$html .= '<div style="background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; text-align: center;">';
$html .= '<div style="font-size: 24px; font-weight: 700; color: #4ade80;">' . $stats['success'] . '</div>';
$html .= '<div style="font-size: 10px; color: #64748b; text-transform: uppercase;">Geslaagd</div></div>';
$html .= '<div style="background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; text-align: center;">';
$html .= '<div style="font-size: 24px; font-weight: 700; color: #f87171;">' . $stats['failed'] . '</div>';
$html .= '<div style="font-size: 10px; color: #64748b; text-transform: uppercase;">Mislukt</div></div>';
$html .= '<div style="background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px; text-align: center;">';
$html .= '<div style="font-size: 24px; font-weight: 700; color: #60a5fa;">' . $stats['success_rate'] . '%</div>';
$html .= '<div style="font-size: 10px; color: #64748b; text-transform: uppercase;">Succes</div></div>';
$html .= '</div>';
// History list
if ($updates === []) {
$html .= '<div style="text-align: center; padding: 24px; color: #64748b;">';
$html .= '<div style="font-size: 32px; margin-bottom: 8px;">📋</div>';
$html .= 'Nog geen update geschiedenis</div>';
} else {
$html .= '<div style="max-height: 400px; overflow-y: auto;">';
foreach ($updates as $update) {
$icon = match ($update->status) {
'success' => '✅',
'failed' => '❌',
'pending' => '⏳',
default => '⚪'
};
$typeColor = match ($update->type) {
'nitro' => '#4ade80',
'emulator' => '#60a5fa',
'sql' => '#fbbf24',
'config' => '#a78bfa',
default => '#94a3b8'
};
$time = Carbon::parse($update->created_at)->diffForHumans();
$html .= '<div style="display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 6px;">';
$html .= '<div style="font-size: 16px;">' . $icon . '</div>';
$html .= '<div style="flex: 1;">';
$html .= '<div style="display: flex; align-items: center; gap: 8px;">';
$html .= '<span style="background: ' . $typeColor . '20; color: ' . $typeColor . '; padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 600; text-transform: uppercase;">' . e($update->type) . '</span>';
$html .= '<span style="color: #e2e8f0; font-size: 13px;">' . e($update->action) . '</span>';
if ($update->item) {
$html .= '<span style="color: #94a3b8; font-size: 12px;">' . e($update->item) . '</span>';
}
$html .= '</div>';
if ($update->message) {
$html .= '<div style="color: #64748b; font-size: 11px; margin-top: 2px;">' . e($update->message) . '</div>';
}
$html .= '</div>';
$html .= '<div style="text-align: right;">';
$html .= '<div style="color: #64748b; font-size: 10px;">' . e($update->user) . '</div>';
$html .= '<div style="color: #475569; font-size: 9px;">' . e($time) . '</div>';
$html .= '</div>';
$html .= '</div>';
}
$html .= '</div>';
}
return $html . '</div>';
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Services\User;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
readonly class SessionService
{
public function fetchSessionLogs(Request $request): Collection
{
return collect(
Auth::user()->sessions,
)->map(function ($session) use ($request) {
$info = $this->parseUserAgent($session->user_agent);
return (object) [
'agent' => [
'is_desktop' => $info['is_desktop'],
'platform' => $info['platform'],
'browser' => $info['browser'],
],
'ip_address' => $session->ip_address,
'is_current_device' => $session->id === $request->session()->getId(),
'last_active' => Carbon::createFromTimestamp($session->last_activity)->diffForHumans(),
];
});
}
public function parseUserAgent(string $userAgent): array
{
$platform = 'Unknown';
$browser = 'Unknown';
$isDesktop = true;
if (preg_match('/Windows NT 10\.0|Windows NT 11\.0|Macintosh|Linux|FreeBSD/', $userAgent)) {
$platform = match (true) {
preg_match('/Windows NT 10\.0/', $userAgent) => 'Windows 10',
preg_match('/Windows NT 11\.0/', $userAgent) => 'Windows 11',
preg_match('/Macintosh/', $userAgent) => 'macOS',
preg_match('/Linux/', $userAgent) => 'Linux',
preg_match('/FreeBSD/', $userAgent) => 'FreeBSD',
default => 'Desktop',
};
} elseif (preg_match('/Android/i', $userAgent)) {
$platform = 'Android';
$isDesktop = false;
} elseif (preg_match('/iPhone|iPad/i', $userAgent)) {
$platform = preg_match('/iPad/', $userAgent) ? 'iPad' : 'iOS';
$isDesktop = false;
}
$browser = match (true) {
preg_match('/Edg\/\d+/', $userAgent) => 'Edge',
preg_match('/Chrome\/\d+/', $userAgent) && ! preg_match('/OPR|Opera/', $userAgent) => 'Chrome',
preg_match('/Firefox\/\d+/', $userAgent) => 'Firefox',
preg_match('/Safari\/\d+/', $userAgent) && ! preg_match('/Chrome/', $userAgent) => 'Safari',
preg_match('/OPR\/\d+/', $userAgent) => 'Opera',
preg_match('/MSIE|Trident\//', $userAgent) => 'Internet Explorer',
default => 'Unknown',
};
return [
'platform' => $platform,
'browser' => $browser,
'is_desktop' => $isDesktop,
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Services\User;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
readonly class UserApiService
{
public function fetchUser(string $username, array $columns): ?User
{
/** @var User|null $user */
$user = User::select($columns)->where('username', '=', $username)->first();
return $user;
}
public function onlineUsers($columns = ['username', 'motto', 'look'], bool $randomOrder = true): Builder|\Illuminate\Database\Query\Builder
{
/** @var Builder $query */
$query = User::select($columns)->where('online', '=', '1');
if ($randomOrder) {
$query = $query->inRandomOrder();
}
return $query;
}
public function onlineUserCount(): int
{
return User::query()->where('online', '=', '1')->count();
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services\User;
use App\Actions\UserActions;
use App\Models\User;
class UserService extends UserActions
{
public function getUser(string $username): ?User
{
/** @var User|null $user */
$user = User::where('username', $username)->first();
return $user;
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace App\Services;
use Illuminate\Foundation\Vite;
class ViteService extends Vite
{
/**
* Generate a script tag for the given URL.
*/
#[\Override]
protected function makeScriptTag($url): string
{
return sprintf('<script type="module" src="%s" data-turbolinks-eval="false"></script>', $url);
}
}