Fix autoload duplicates and improve footer/linting

This commit is contained in:
2026-02-10 23:11:36 +02:00
parent a9f8670224
commit e22d73eba5
7 changed files with 139 additions and 1893 deletions

6
.stylelintignore Normal file
View File

@@ -0,0 +1,6 @@
vendor/
node_modules/
public/build/
public/vendor/
public/fonts/
public/css/filament/

18
.stylelintrc.json Normal file
View File

@@ -0,0 +1,18 @@
{
"rules": {
"at-rule-no-unknown": [
true,
{
"ignoreAtRules": [
"tailwind",
"apply",
"layer",
"variants",
"responsive",
"screen",
"theme"
]
}
]
}
}

View File

@@ -1,235 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class BackupSchedule extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'destination_id',
'name',
'is_active',
'is_server_backup',
'frequency',
'time',
'day_of_week',
'day_of_month',
'include_files',
'include_databases',
'include_mailboxes',
'include_dns',
'domains',
'databases',
'mailboxes',
'users',
'retention_count',
'last_run_at',
'next_run_at',
'last_status',
'last_error',
'metadata',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'is_server_backup' => 'boolean',
'include_files' => 'boolean',
'include_databases' => 'boolean',
'include_mailboxes' => 'boolean',
'include_dns' => 'boolean',
'domains' => 'array',
'databases' => 'array',
'mailboxes' => 'array',
'users' => 'array',
'metadata' => 'array',
'retention_count' => 'integer',
'day_of_week' => 'integer',
'day_of_month' => 'integer',
'last_run_at' => 'datetime',
'next_run_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function destination(): BelongsTo
{
return $this->belongsTo(BackupDestination::class, 'destination_id');
}
public function backups(): HasMany
{
return $this->hasMany(Backup::class, 'schedule_id');
}
/**
* Check if the schedule should run now.
*/
public function shouldRun(): bool
{
if (! $this->is_active) {
return false;
}
if (! $this->next_run_at) {
return true;
}
return $this->next_run_at->isPast();
}
/**
* Calculate and set the next run time.
*/
public function calculateNextRun(): Carbon
{
$timezone = $this->getSystemTimezone();
$now = Carbon::now($timezone);
$time = explode(':', $this->time);
$hour = (int) ($time[0] ?? 2);
$minute = (int) ($time[1] ?? 0);
$next = $now->copy()->setTime($hour, $minute, 0);
// If time already passed today, start from tomorrow
if ($next->isPast()) {
$next->addDay();
}
switch ($this->frequency) {
case 'hourly':
$next = $now->copy()->addHour()->startOfHour();
break;
case 'daily':
// Already set to next occurrence
break;
case 'weekly':
$targetDay = $this->day_of_week ?? 0; // Default to Sunday
while ($next->dayOfWeek !== $targetDay) {
$next->addDay();
}
break;
case 'monthly':
$targetDay = $this->day_of_month ?? 1;
$next->day = min($targetDay, $next->daysInMonth);
if ($next->isPast()) {
$next->addMonth();
$next->day = min($targetDay, $next->daysInMonth);
}
break;
}
$nextUtc = $next->copy()->setTimezone('UTC');
$this->attributes['next_run_at'] = $nextUtc->format($this->getDateFormat());
return $nextUtc;
}
/**
* Get frequency label for UI.
*/
public function getFrequencyLabelAttribute(): string
{
$base = match ($this->frequency) {
'hourly' => 'Every hour',
'daily' => 'Daily at '.$this->time,
'weekly' => 'Weekly on '.$this->getDayName().' at '.$this->time,
'monthly' => 'Monthly on day '.($this->day_of_month ?? 1).' at '.$this->time,
default => ucfirst($this->frequency),
};
return $base;
}
/**
* Get day name for weekly schedules.
*/
protected function getDayName(): string
{
$days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return $days[$this->day_of_week ?? 0];
}
protected function getSystemTimezone(): string
{
static $timezone = null;
if ($timezone === null) {
$timezone = trim((string) @file_get_contents('/etc/timezone'));
if ($timezone === '') {
$timezone = trim((string) @shell_exec('timedatectl show -p Timezone --value 2>/dev/null'));
}
if ($timezone === '') {
$timezone = 'UTC';
}
}
return $timezone;
}
/**
* Scope for active schedules.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope for due schedules.
*/
public function scopeDue($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('next_run_at')
->orWhere('next_run_at', '<=', now());
});
}
/**
* Scope for user schedules.
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope for server backup schedules.
*/
public function scopeServerBackups($query)
{
return $query->where('is_server_backup', true);
}
/**
* Get last status color for UI.
*/
public function getLastStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'failed' => 'danger',
default => 'gray',
};
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -185,5 +185,53 @@ class BackupSchedule extends Model
return $timezone;
}
/**
* Scope for active schedules.
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope for due schedules.
*/
public function scopeDue($query)
{
return $query->active()
->where(function ($q) {
$q->whereNull('next_run_at')
->orWhere('next_run_at', '<=', now());
});
}
/**
* Scope for user schedules.
*/
public function scopeForUser($query, int $userId)
{
return $query->where('user_id', $userId);
}
/**
* Scope for server backup schedules.
*/
public function scopeServerBackups($query)
{
return $query->where('is_server_backup', true);
}
/**
* Get last status color for UI.
*/
public function getLastStatusColorAttribute(): string
{
return match ($this->last_status) {
'success' => 'success',
'failed' => 'danger',
default => 'gray',
};
}
}

View File

@@ -92,7 +92,7 @@ class User extends Authenticatable implements FilamentUser
]);
}
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
}
@@ -100,32 +100,37 @@ class User extends Authenticatable implements FilamentUser
$masterUser = $user->username.'_admin';
try {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (class_exists(\mysqli::class)) {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Delete stored credentials
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
}
try {
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Throwable $e) {
\Log::error('Failed to delete stored MySQL credentials: '.$e->getMessage());
}
});
}

View File

@@ -1,33 +1,53 @@
@php
$jabaliVersion = '1.0.1';
$jabaliVersion = 'unknown';
$versionFile = base_path('VERSION');
if (file_exists($versionFile)) {
$content = file_get_contents($versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
if (preg_match('/^VERSION=(.+)$/m', $content, $matches)) {
$jabaliVersion = trim($matches[1]);
}
}
@endphp
<style>
.dark .jabali-footer-logo { filter: invert(1) brightness(2); }
</style>
<div style="border-top: 1px solid rgba(128,128,128,0.1); padding: 20px 24px; margin-top: auto;">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
<div style="display: flex; align-items: center; gap: 12px;">
<img src="{{ asset('images/jabali_logo.svg') }}" alt="Jabali" style="height: 32px; width: 32px;" class="jabali-footer-logo">
<div>
<div style="font-weight: 600; font-size: 15px;" class="text-gray-700 dark:text-gray-200">Jabali Panel</div>
<div style="font-size: 12px;" class="text-gray-500 dark:text-gray-400">Web Hosting Control Panel</div>
<footer class="mt-auto border-t border-gray-200/20 px-6 py-5 dark:border-white/10">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex items-center gap-3">
<img
src="{{ asset('images/jabali_logo.svg') }}"
alt="{{ __('Jabali') }}"
class="h-8 w-8 dark:filter dark:invert dark:brightness-200"
>
<div class="leading-tight">
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200">
{{ __('Jabali Panel') }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ __('Web Hosting Control Panel') }}
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 16px; font-size: 13px;" class="text-gray-500 dark:text-gray-400">
<a href="https://github.com/shukiv/jabali-panel" target="_blank" style="display: flex; align-items: center; gap: 6px; text-decoration: none;" class="text-gray-500 dark:text-gray-400 hover:text-blue-500">
<svg style="width: 16px; height: 16px;" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
GitHub
</a>
<span style="color: rgba(128,128,128,0.3);"></span>
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
<x-filament::link
tag="a"
href="https://github.com/shukiv/jabali-panel"
target="_blank"
rel="noopener"
color="gray"
size="sm"
>
{{ __('GitHub') }}
</x-filament::link>
<span class="text-gray-400/50 dark:text-white/20"></span>
<span>© {{ date('Y') }} Jabali</span>
<span style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;">v{{ $jabaliVersion }}</span>
<x-filament::badge size="sm" color="gray">
v{{ $jabaliVersion }}
</x-filament::badge>
</div>
</div>
</div>
</footer>