29 Commits

Author SHA1 Message Date
e230ac17aa Ship migration, deploy workflow, and security hardening updates 2026-02-12 23:59:57 +02:00
Jabali Deploy
7125c535cc Include local env example updates (v0.9-rc65) 2026-02-12 01:05:42 +00:00
Jabali Deploy
2dfc139f42 Sync install fallback with VERSION 0.9-rc64 2026-02-12 01:05:01 +00:00
Jabali Deploy
52e116e671 Push all local workspace changes (v0.9-rc64) 2026-02-12 01:04:13 +00:00
Jabali Deploy
0c6402604d Deploy sync from local workspace (v0.9-rc63) 2026-02-12 00:41:14 +00:00
5d502699ea Bump VERSION to 0.9-rc62 2026-02-11 20:28:19 +02:00
967df591d6 Improve staging flow, UI fixes, and deploy automation 2026-02-11 20:28:05 +02:00
2bdf7395fc Bump VERSION to 0.9-rc61 2026-02-11 03:58:19 +02:00
c4acf0b658 Unify user migrations under Migration tabs 2026-02-11 03:03:55 +02:00
ed5e3f2bda Fix import.start for PHP 8.4 escapeshellarg types 2026-02-11 02:21:58 +02:00
070e46cf77 Ignore backups folder exists when uploading 2026-02-11 02:13:11 +02:00
a566a2ae64 Relax upload type checks for .zst backups 2026-02-11 01:54:30 +02:00
1e66f43d4e Allow users to upload DirectAdmin backups 2026-02-11 01:23:08 +02:00
443b05a677 Support DirectAdmin .tar.zst backups 2026-02-11 00:38:05 +02:00
13685615cb Use backups folder for DirectAdmin backup restores 2026-02-11 00:22:27 +02:00
e7920366d7 Add DirectAdmin migration UI (Phase 1) 2026-02-10 23:51:34 +02:00
3fa6399b27 Fix tests and document screenshots 2026-02-10 23:11:36 +02:00
e22d73eba5 Fix autoload duplicates and improve footer/linting 2026-02-10 23:11:36 +02:00
a9f8670224 Add DirectAdmin migration blueprint 2026-02-10 22:04:55 +02:00
386c759e70 Bump VERSION to 0.9-rc60 2026-02-10 21:59:09 +02:00
c1599f5dd1 Bump VERSION to 0.9-rc59 2026-02-10 18:27:55 +02:00
6064de6c81 Refine support page content 2026-02-10 18:27:31 +02:00
f7902105de Bump VERSION to 0.9-rc58 2026-02-09 15:34:36 +02:00
b049d338d8 Expand support page help options 2026-02-09 15:34:31 +02:00
8573d96719 Bump VERSION to 0.9-rc57 2026-02-09 14:58:24 +02:00
800e07d2ba Update onboarding, support pages, and deploy tooling 2026-02-09 14:58:04 +02:00
c6f5b6cab8 Replace custom HTML activity log table with Filament EmbeddedTable
The activity tab on the user Logs page used a raw HTML table with
Tailwind classes. This replaces it with a proper Filament embedded
table widget (ActivityLogTable) for consistent styling, pagination,
badges, and dark mode support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:50:49 +00:00
root
8acc55a799 Refine database warning banner and docs 2026-02-06 18:46:16 +00:00
root
a5742a3156 Add confirmations for service disabling 2026-02-06 17:00:13 +00:00
83 changed files with 6023 additions and 2287 deletions

View File

@@ -29,6 +29,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=null
# SESSION_SECURE_COOKIE=true
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -59,4 +60,13 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
# Comma-separated list of trusted proxies. Use "*" only when intentional.
# TRUSTED_PROXIES=127.0.0.1,::1
# Optional internal API shared token for non-localhost calls.
# JABALI_INTERNAL_API_TOKEN=
# Set to true only when remote panel migration discovery must skip TLS verification.
# JABALI_IMPORT_INSECURE_TLS=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ CLAUDE.md
/jabali-panel_*.deb /jabali-panel_*.deb
/jabali-deps_*.deb /jabali-deps_*.deb
.git-credentials .git-credentials
# Local repository configuration (do not commit)
config.toml

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

@@ -58,6 +58,7 @@ php artisan route:cache # Cache routes
## Git Workflow ## Git Workflow
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits. **Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured).
### Version Numbers ### Version Numbers
@@ -620,7 +621,15 @@ All administrative actions are logged to the `audit_logs` table.
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments - **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop - **MUST be responsive** - All pages must work on mobile, tablet, and desktop
### Warning Banners
- Use Filament `Section::make()` for warning banners (no raw HTML).
- Always set `->icon('heroicon-o-exclamation-triangle')` and `->iconColor('warning')`.
- Keep banners non-collapsible: `->collapsed(false)->collapsible(false)`.
- Put the full message in `->description()` and keep the heading short.
### Allowed Components ### Allowed Components
Use these Filament native components exclusively: Use these Filament native components exclusively:
| Category | Components | | Category | Components |

View File

@@ -17,6 +17,7 @@ Rules and behavior for automated agents working on Jabali.
- Do not push unless the user explicitly asks. - Do not push unless the user explicitly asks.
- Bump `VERSION` before every push. - Bump `VERSION` before every push.
- Keep `install.sh` version fallback in sync with `VERSION`. - Keep `install.sh` version fallback in sync with `VERSION`.
- Push to GitHub from `root@192.168.100.50`.
## Operational ## Operational
- If you add dependencies, update both install and uninstall paths. - If you add dependencies, update both install and uninstall paths.

View File

@@ -5,7 +5,7 @@
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server. A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server.
Version: 0.9-rc51 (release candidate) Version: see `VERSION` (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0. This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
@@ -27,6 +27,25 @@ This is a release candidate. Expect rapid iteration and breaking changes until 1
- Security center with firewall, Fail2ban, ClamAV, and scanners - Security center with firewall, Fail2ban, ClamAV, and scanners
- Audit logs and admin notifications - Audit logs and admin notifications
## Screenshots
Admin panel:
- [Admin Dashboard](docs/screenshots/admin-dashboard.png)
- [Admin Server Status](docs/screenshots/admin-server-status.png)
- [Admin Server Settings](docs/screenshots/admin-server-settings.png)
- [Admin Security](docs/screenshots/admin-security.png)
- [Admin Users](docs/screenshots/admin-users.png)
- [Admin SSL Manager](docs/screenshots/admin-ssl-manager.png)
- [Admin DNS Zones](docs/screenshots/admin-dns-zones.png)
- [Admin Backups](docs/screenshots/admin-backups.png)
- [Admin Services](docs/screenshots/admin-services.png)
User panel:
- [User Dashboard](docs/screenshots/user-dashboard.png)
- [User Domains](docs/screenshots/user-domains.png)
- [User Backups](docs/screenshots/user-backups.png)
- [User cPanel Migration](docs/screenshots/user-cpanel-migration.png)
## Installation ## Installation
GitHub install: GitHub install:
@@ -141,6 +160,13 @@ Service stack (single-node default):
- PTR (reverse DNS) for mail hostname - PTR (reverse DNS) for mail hostname
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53 - Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
## Security Hardening
- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies).
- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost.
- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification.
- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256).
## Upgrades ## Upgrades
``` ```

View File

@@ -1 +1 @@
VERSION=0.9-rc54 VERSION=0.9-rc66

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

@@ -18,6 +18,7 @@ use Illuminate\Support\Str;
class ImportProcessCommand extends Command class ImportProcessCommand extends Command
{ {
protected $signature = 'import:process {import_id : The server import ID to process}'; protected $signature = 'import:process {import_id : The server import ID to process}';
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)'; protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
private ?AgentClient $agent = null; private ?AgentClient $agent = null;
@@ -29,6 +30,7 @@ class ImportProcessCommand extends Command
$import = ServerImport::with('accounts')->find($importId); $import = ServerImport::with('accounts')->find($importId);
if (! $import) { if (! $import) {
$this->error("Import not found: $importId"); $this->error("Import not found: $importId");
return 1; return 1;
} }
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
'current_task' => null, 'current_task' => null,
]); ]);
$import->addError('No accounts selected for import'); $import->addError('No accounts selected for import');
return 1; return 1;
} }
@@ -67,7 +70,7 @@ class ImportProcessCommand extends Command
'status' => 'failed', 'status' => 'failed',
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
$account->addLog("Import failed: " . $e->getMessage()); $account->addLog('Import failed: '.$e->getMessage());
$import->addError("Account {$account->source_username}: ".$e->getMessage()); $import->addError("Account {$account->source_username}: ".$e->getMessage());
} }
} }
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
'completed_at' => now(), 'completed_at' => now(),
'progress' => 100, 'progress' => 100,
]); ]);
$import->addLog("All accounts imported successfully"); $import->addLog('All accounts imported successfully');
} }
$this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount"); $this->info('Import completed. Success: '.($totalAccounts - $failedCount).", Failed: $failedCount");
return 0; return 0;
} }
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
private function getAgent(): AgentClient private function getAgent(): AgentClient
{ {
if ($this->agent === null) { if ($this->agent === null) {
$this->agent = new AgentClient(); $this->agent = new AgentClient;
} }
return $this->agent; return $this->agent;
} }
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
if ($account->main_domain) { if ($account->main_domain) {
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]); $account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
$this->createDomains($account, $user); $this->createDomains($account, $user);
$account->addLog("Created domains"); $account->addLog('Created domains');
} }
// Step 3: Import files // Step 3: Import files
if ($options['files'] ?? true) { if ($options['files'] ?? true) {
$account->update(['current_task' => 'Importing files...', 'progress' => 40]); $account->update(['current_task' => 'Importing files...', 'progress' => 40]);
$this->importFiles($import, $account, $user); $this->importFiles($import, $account, $user);
$account->addLog("Files imported"); $account->addLog('Files imported');
} }
// Step 4: Import databases // Step 4: Import databases
if (($options['databases'] ?? true) && ! empty($account->databases)) { if (($options['databases'] ?? true) && ! empty($account->databases)) {
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]); $account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
$this->importDatabases($import, $account, $user); $this->importDatabases($import, $account, $user);
$account->addLog("Databases imported"); $account->addLog('Databases imported');
} }
// Step 5: Import emails // Step 5: Import emails
if (($options['emails'] ?? true) && ! empty($account->email_accounts)) { if (($options['emails'] ?? true) && ! empty($account->email_accounts)) {
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]); $account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
$this->importEmails($import, $account, $user); $this->importEmails($import, $account, $user);
$account->addLog("Email accounts imported"); $account->addLog('Email accounts imported');
} }
$account->update([ $account->update([
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
'progress' => 100, 'progress' => 100,
'current_task' => null, 'current_task' => null,
]); ]);
$account->addLog("Import completed successfully"); $account->addLog('Import completed successfully');
} }
private function createUser(ServerImportAccount $account): User private function createUser(ServerImportAccount $account): User
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
$existingUser = User::where('username', $account->target_username)->first(); $existingUser = User::where('username', $account->target_username)->first();
if ($existingUser) { if ($existingUser) {
$account->addLog("User already exists: {$account->target_username}"); $account->addLog("User already exists: {$account->target_username}");
return $existingUser; return $existingUser;
} }
@@ -180,7 +185,7 @@ class ImportProcessCommand extends Command
$result = $this->getAgent()->createUser($account->target_username, $password); $result = $this->getAgent()->createUser($account->target_username, $password);
if (! ($result['success'] ?? false)) { if (! ($result['success'] ?? false)) {
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error')); throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
} }
// Create user in database // Create user in database
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
'password' => Hash::make($password), 'password' => Hash::make($password),
]); ]);
$account->addLog("Created user with temporary password. User should reset password."); $account->addLog('Created user with temporary password. User should reset password.');
return $user; return $user;
} }
@@ -208,12 +213,12 @@ class ImportProcessCommand extends Command
Domain::create([ Domain::create([
'domain' => $account->main_domain, 'domain' => $account->main_domain,
'user_id' => $user->id, 'user_id' => $user->id,
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public", 'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public_html",
'is_active' => true, 'is_active' => true,
]); ]);
$account->addLog("Created main domain: {$account->main_domain}"); $account->addLog("Created main domain: {$account->main_domain}");
} else { } else {
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown')); $account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
} }
} else { } else {
$account->addLog("Main domain already exists: {$account->main_domain}"); $account->addLog("Main domain already exists: {$account->main_domain}");
@@ -230,7 +235,7 @@ class ImportProcessCommand extends Command
Domain::create([ Domain::create([
'domain' => $domain, 'domain' => $domain,
'user_id' => $user->id, 'user_id' => $user->id,
'document_root' => "/home/{$user->username}/domains/{$domain}/public", 'document_root' => "/home/{$user->username}/domains/{$domain}/public_html",
'is_active' => true, 'is_active' => true,
]); ]);
$account->addLog("Created addon domain: {$domain}"); $account->addLog("Created addon domain: {$domain}");
@@ -244,30 +249,37 @@ class ImportProcessCommand extends Command
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
{ {
if ($import->import_method !== 'backup_file' || ! $import->backup_path) { if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog("File import skipped - not a backup file import"); $account->addLog('File import skipped - not a backup file import');
return; return;
} }
$backupPath = Storage::disk('local')->path($import->backup_path); $backupPath = $this->resolveBackupFullPath($import);
if (!file_exists($backupPath)) { if (! $backupPath) {
$account->addLog("Warning: Backup file not found"); $account->addLog('Warning: Backup file not found');
return; return;
} }
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time(); $extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
if (! mkdir($extractDir, 0755, true)) { if (! mkdir($extractDir, 0755, true)) {
$account->addLog("Warning: Failed to create extraction directory"); $account->addLog('Warning: Failed to create extraction directory');
return; return;
} }
try { try {
$username = $account->source_username; $username = $account->source_username;
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
if ($import->source_type === 'cpanel') { if ($import->source_type === 'cpanel') {
// Extract home directory from cPanel backup // Extract home directory from cPanel backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . $cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null"; " --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
exec($cmd, $output, $code); exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract backup archive');
}
// Find extracted files // Find extracted files
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?: $homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
@@ -278,19 +290,22 @@ class ImportProcessCommand extends Command
// Copy public_html to the domain // Copy public_html to the domain
$publicHtml = "$homeDir/public_html"; $publicHtml = "$homeDir/public_html";
if (is_dir($publicHtml) && $account->main_domain) { if (is_dir($publicHtml) && $account->main_domain) {
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public"; $destDir = "/home/{$user->username}/domains/{$account->main_domain}/public_html";
if (is_dir($destDir)) { if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1"); exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1"); exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
$account->addLog("Copied public_html to {$account->main_domain}"); $account->addLog("Copied public_html to {$account->main_domain}");
} }
} }
} }
} else { } else {
// Extract from DirectAdmin backup // Extract from DirectAdmin backup
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . $cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null"; " --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
exec($cmd, $output, $code); exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
}
// Find domain directories // Find domain directories
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?: $domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
@@ -301,10 +316,10 @@ class ImportProcessCommand extends Command
$publicHtml = "$domainDir/public_html"; $publicHtml = "$domainDir/public_html";
if (is_dir($publicHtml)) { if (is_dir($publicHtml)) {
$destDir = "/home/{$user->username}/domains/{$domain}/public"; $destDir = "/home/{$user->username}/domains/{$domain}/public_html";
if (is_dir($destDir)) { if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1"); exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1"); exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
$account->addLog("Copied files for domain: {$domain}"); $account->addLog("Copied files for domain: {$domain}");
} }
} }
@@ -312,19 +327,20 @@ class ImportProcessCommand extends Command
} }
} finally { } finally {
// Cleanup // Cleanup
exec("rm -rf " . escapeshellarg($extractDir)); exec('rm -rf '.escapeshellarg($extractDir));
} }
} }
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
{ {
if ($import->import_method !== 'backup_file' || ! $import->backup_path) { if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog("Database import skipped - not a backup file import"); $account->addLog('Database import skipped - not a backup file import');
return; return;
} }
$backupPath = Storage::disk('local')->path($import->backup_path); $backupPath = $this->resolveBackupFullPath($import);
if (!file_exists($backupPath)) { if (! $backupPath) {
return; return;
} }
@@ -334,22 +350,32 @@ class ImportProcessCommand extends Command
} }
try { try {
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
// Extract MySQL dumps // Extract MySQL dumps
if ($import->source_type === 'cpanel') { if ($import->source_type === 'cpanel') {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . $cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null"; " --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
} else { } else {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) . $cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null"; " --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
} }
exec($cmd, $output, $code); exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract database dumps from backup archive');
}
// Find SQL files // Find SQL files
$sqlFiles = []; $sqlFiles = [];
exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles); exec('find '.escapeshellarg($extractDir)." -type f \\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.sql.zst' \\) 2>/dev/null", $sqlFiles);
foreach ($sqlFiles as $sqlFile) { foreach ($sqlFiles as $sqlFile) {
$dbName = basename($sqlFile, '.sql'); $fileName = basename($sqlFile);
$dbName = preg_replace('/\\.(sql|sql\\.gz|sql\\.zst)$/i', '', $fileName);
if (! is_string($dbName) || $dbName === '') {
continue;
}
// Create database name with user prefix // Create database name with user prefix
$newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64); $newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
@@ -358,8 +384,40 @@ class ImportProcessCommand extends Command
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName); $result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
$sqlToImport = $sqlFile;
$tmpSql = null;
try {
$lower = strtolower($sqlFile);
if (str_ends_with($lower, '.sql.gz')) {
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
$decompressCmd = 'gzip -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
exec($decompressCmd, $decompressOutput, $decompressCode);
if ($decompressCode !== 0) {
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
continue;
}
$sqlToImport = $tmpSql;
} elseif (str_ends_with($lower, '.sql.zst')) {
$tmpSql = $extractDir.'/import_'.$account->id.'_'.$dbName.'_'.uniqid('', true).'.sql';
$decompressCmd = 'zstd -dc '.escapeshellarg($sqlFile).' > '.escapeshellarg($tmpSql).' 2>/dev/null';
exec($decompressCmd, $decompressOutput, $decompressCode);
if ($decompressCode !== 0) {
$account->addLog("Warning: Failed to decompress database dump: {$fileName}");
continue;
}
$sqlToImport = $tmpSql;
}
// Import data // Import data
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1"; $cmd = 'mysql '.escapeshellarg($newDbName).' < '.escapeshellarg($sqlToImport).' 2>&1';
exec($cmd, $importOutput, $importCode); exec($cmd, $importOutput, $importCode);
if ($importCode === 0) { if ($importCode === 0) {
@@ -367,12 +425,17 @@ class ImportProcessCommand extends Command
} else { } else {
$account->addLog("Warning: Database created but import failed: {$newDbName}"); $account->addLog("Warning: Database created but import failed: {$newDbName}");
} }
} finally {
if ($tmpSql && file_exists($tmpSql)) {
@unlink($tmpSql);
}
}
} else { } else {
$account->addLog("Warning: Failed to create database: {$newDbName}"); $account->addLog("Warning: Failed to create database: {$newDbName}");
} }
} }
} finally { } finally {
exec("rm -rf " . escapeshellarg($extractDir)); exec('rm -rf '.escapeshellarg($extractDir));
} }
} }
@@ -384,6 +447,45 @@ class ImportProcessCommand extends Command
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}"); $account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
} }
$account->addLog("Note: Email accounts must be recreated manually"); $account->addLog('Note: Email accounts must be recreated manually');
}
private function resolveBackupFullPath(ServerImport $import): ?string
{
$path = trim((string) ($import->backup_path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
private function getTarExtractCommandPrefix(string $archivePath): string
{
$archivePath = strtolower($archivePath);
if (str_ends_with($archivePath, '.tar.zst') || str_ends_with($archivePath, '.zst')) {
return 'tar --zstd -xf';
}
if (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
return 'tar -xzf';
}
return 'tar -xf';
} }
} }

View File

@@ -14,9 +14,12 @@ use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms; use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms; use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable; use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
@@ -50,6 +53,13 @@ class Dashboard extends Page implements HasActions, HasForms
]; ];
} }
public function mount(): void
{
if (! DnsSetting::get('onboarding_completed', false)) {
$this->defaultAction = 'onboarding';
}
}
protected function getForms(): array protected function getForms(): array
{ {
return [ return [
@@ -78,27 +88,86 @@ class Dashboard extends Page implements HasActions, HasForms
->color('gray') ->color('gray')
->action(fn () => $this->redirect(request()->url())), ->action(fn () => $this->redirect(request()->url())),
Action::make('onboarding') Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard')) ->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles') ->icon('heroicon-o-sparkles')
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
->modalHeading(__('Welcome to Jabali!')) ->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.')) ->modalDescription(__('Let\'s get your server control panel set up.'))
->modalWidth('md') ->modalWidth('2xl')
->fillForm(function (): array {
$savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', ''));
$savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]);
return [
'admin_email' => $savedPrimaryEmail,
];
})
->form([ ->form([
Section::make(__('Next Steps'))
->description(__('Here is a quick setup path to get your first site online.'))
->icon('heroicon-o-check-circle')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact()
->schema([
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('1. Configure Server Settings'))
->description(__('Set hostname, DNS, email, storage, and PHP defaults.'))
->icon('heroicon-o-cog-6-tooth')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('2. Create a Hosting Package'))
->description(__('Define limits and features for your plans.'))
->icon('heroicon-o-cube')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('3. Create a User'))
->description(__('Assign the hosting package and set credentials.'))
->icon('heroicon-o-user-plus')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('4. Add a Domain'))
->description(__('Issue SSL and deploy your site files.'))
->icon('heroicon-o-globe-alt')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
]),
Text::make(__('Optional: review Services and Server Status to confirm everything is healthy.'))
->color('gray'),
]),
TextInput::make('admin_email') TextInput::make('admin_email')
->label(__('Your Email Address')) ->label(__('Your Email Address'))
->helperText(__('Enter your email to receive important server notifications.')) ->helperText(__('Enter your email to receive important server notifications.'))
->email() ->email()
->placeholder(__('admin@example.com')), ->placeholder(__('admin@example.com')),
]) ])
->modalSubmitActionLabel(__('Get Started')) ->modalSubmitActionLabel(__('Save and close'))
->action(function (array $data): void { ->action(function (array $data): void {
if (! empty($data['admin_email'])) { $adminEmail = trim((string) ($data['admin_email'] ?? ''));
DnsSetting::set('admin_email_recipients', $data['admin_email']);
if ($adminEmail !== '') {
DnsSetting::set('admin_email_recipients', $adminEmail);
} }
DnsSetting::set('onboarding_completed', '1'); DnsSetting::set('onboarding_completed', '1');
DnsSetting::clearCache(); DnsSetting::clearCache();
Notification::make()
->title(__('Setup saved'))
->body(__('Your notification email has been updated.'))
->success()
->send();
}), }),
]; ];
} }

View File

@@ -0,0 +1,779 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions as FormActions;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
class DirectAdminMigration extends Page implements HasActions, HasForms
{
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
protected static ?string $slug = 'directadmin-migration';
protected string $view = 'filament.admin.pages.directadmin-migration';
#[Url(as: 'directadmin-step')]
public ?string $wizardStep = null;
public bool $step1Complete = false;
public ?int $importId = null;
public ?string $name = null;
public string $importMethod = 'remote_server'; // remote_server|backup_file
public ?string $remoteHost = null;
public int $remotePort = 2222;
public ?string $remoteUser = null;
public ?string $remotePassword = null;
public ?string $backupPath = null;
public ?string $backupFilePath = null;
public bool $importFiles = true;
public bool $importDatabases = true;
public bool $importEmails = true;
public bool $importSsl = true;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('DirectAdmin Migration');
}
public function getTitle(): string|Htmlable
{
return __('DirectAdmin Migration');
}
public function getSubheading(): ?string
{
return __('Migrate DirectAdmin accounts into Jabali');
}
protected function getHeaderActions(): array
{
return [
Action::make('startOver')
->label(__('Start Over'))
->icon('heroicon-o-arrow-path')
->color('gray')
->requiresConfirmation()
->modalHeading(__('Start Over'))
->modalDescription(__('This will reset the DirectAdmin migration wizard. Are you sure?'))
->action('resetMigration'),
];
}
public function mount(): void
{
$this->restoreFromSession();
$this->restoreFromImport();
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Wizard::make([
$this->getConnectStep(),
$this->getSelectAccountsStep(),
$this->getConfigureStep(),
$this->getMigrateStep(),
])
->persistStepInQueryString('directadmin-step'),
]);
}
protected function getConnectStep(): Step
{
return Step::make(__('Connect'))
->id('connect')
->icon('heroicon-o-link')
->description(__('Connect to DirectAdmin or upload a backup'))
->schema([
Section::make(__('Source'))
->description(__('Choose how you want to migrate DirectAdmin accounts.'))
->icon('heroicon-o-server')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
TextInput::make('name')
->label(__('Import Name'))
->default(fn (): string => $this->name ?: ('DirectAdmin Import '.now()->format('Y-m-d H:i')))
->maxLength(255)
->required(),
Radio::make('importMethod')
->label(__('Import Method'))
->options([
'remote_server' => __('Remote Server'),
'backup_file' => __('Backup File'),
])
->default('remote_server')
->live(),
]),
Grid::make(['default' => 1, 'sm' => 2])
->schema([
TextInput::make('remoteHost')
->label(__('Host'))
->placeholder('directadmin.example.com')
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePort')
->label(__('Port'))
->numeric()
->default(2222)
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remoteUser')
->label(__('Username'))
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePassword')
->label(__('Password'))
->password()
->revealable()
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
]),
FileUpload::make('backupPath')
->label(__('DirectAdmin Backup Archive'))
->helperText(__('Upload a DirectAdmin backup archive (usually .tar.zst) to the server backups folder.'))
->disk('backups')
->directory('directadmin-migrations')
->preserveFilenames()
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
TextInput::make('backupFilePath')
->label(__('Backup File Path'))
->placeholder('/var/backups/jabali/directadmin-migrations/user.tar.zst')
->helperText(__('Use this if the backup file already exists on the server.'))
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
Text::make(__('Tip: Upload backups to /var/backups/jabali/directadmin-migrations/'))->color('gray')
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file'),
FormActions::make([
Action::make('discoverAccounts')
->label(__('Discover Accounts'))
->icon('heroicon-o-magnifying-glass')
->color('primary')
->action('discoverAccounts'),
])->alignEnd(),
]),
Section::make(__('Discovery'))
->description(__('Once accounts are discovered, proceed to select which ones to import.'))
->icon('heroicon-o-user-group')
->schema([
Text::make(__('Discovered accounts will appear in the next step.'))->color('gray'),
]),
])
->afterValidation(function () {
$import = $this->getImport();
$hasAccounts = $import?->accounts()->exists() ?? false;
if (! $hasAccounts) {
Notification::make()
->title(__('No accounts discovered'))
->body(__('Click "Discover Accounts" to continue.'))
->danger()
->send();
throw new Exception(__('No accounts discovered'));
}
$this->step1Complete = true;
$this->saveToSession();
});
}
protected function getSelectAccountsStep(): Step
{
return Step::make(__('Select Accounts'))
->id('accounts')
->icon('heroicon-o-users')
->description(__('Choose which DirectAdmin accounts to migrate'))
->schema([
Section::make(__('DirectAdmin Accounts'))
->description(fn (): string => $this->getAccountsStepDescription())
->icon('heroicon-o-user-group')
->headerActions([
Action::make('refreshAccounts')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action('refreshAccountsTable'),
Action::make('selectAll')
->label(__('Select All'))
->icon('heroicon-o-check')
->color('primary')
->action('selectAllAccounts')
->visible(fn (): bool => $this->getSelectedAccountsCount() < $this->getDiscoveredAccountsCount()),
Action::make('deselectAll')
->label(__('Deselect All'))
->icon('heroicon-o-x-mark')
->color('gray')
->action('deselectAllAccounts')
->visible(fn (): bool => $this->getSelectedAccountsCount() > 0),
])
->schema([
View::make('filament.admin.pages.directadmin-accounts-table'),
]),
])
->afterValidation(function () {
if ($this->getSelectedAccountsCount() === 0) {
Notification::make()
->title(__('No accounts selected'))
->body(__('Please select at least one account to migrate.'))
->danger()
->send();
throw new Exception(__('No accounts selected'));
}
$this->saveToSession();
});
}
protected function getConfigureStep(): Step
{
return Step::make(__('Configure'))
->id('configure')
->icon('heroicon-o-cog')
->description(__('Choose what to import and map accounts'))
->schema([
Section::make(__('What to Import'))
->description(__('Select which parts of each account to import.'))
->icon('heroicon-o-check-circle')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
Checkbox::make('importFiles')
->label(__('Website Files'))
->helperText(__('Restore website files from the backup'))
->default(true),
Checkbox::make('importDatabases')
->label(__('Databases'))
->helperText(__('Restore MySQL databases and import dumps'))
->default(true),
Checkbox::make('importEmails')
->label(__('Email'))
->helperText(__('Create email domains and mailboxes (limited in Phase 1)'))
->default(true),
Checkbox::make('importSsl')
->label(__('SSL'))
->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)'))
->default(true),
]),
]),
Section::make(__('Account Mappings'))
->description(fn (): string => __(':count account(s) selected', ['count' => $this->getSelectedAccountsCount()]))
->icon('heroicon-o-arrow-right')
->schema([
View::make('filament.admin.pages.directadmin-account-config-table'),
]),
])
->afterValidation(function (): void {
$import = $this->getImport();
if (! $import) {
throw new Exception(__('Import job not found'));
}
$import->update([
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
]);
$this->saveToSession();
$this->dispatch('directadmin-config-updated');
});
}
protected function getMigrateStep(): Step
{
return Step::make(__('Migrate'))
->id('migrate')
->icon('heroicon-o-play')
->description(__('Run the migration and watch progress'))
->schema([
FormActions::make([
Action::make('startMigration')
->label(__('Start Migration'))
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading(__('Start Migration'))
->modalDescription(__('This will migrate :count account(s). Continue?', ['count' => $this->getSelectedAccountsCount()]))
->action('startMigration'),
Action::make('newMigration')
->label(__('New Migration'))
->icon('heroicon-o-plus')
->color('primary')
->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed')
->action('resetMigration'),
])->alignEnd(),
Section::make(__('Import Status'))
->icon('heroicon-o-queue-list')
->schema([
View::make('filament.admin.pages.directadmin-migration-status-table'),
]),
]);
}
public function discoverAccounts(): void
{
try {
$import = $this->upsertImportForDiscovery();
$backupFullPath = null;
$remotePassword = null;
if ($this->importMethod === 'backup_file') {
if (! $import->backup_path) {
throw new Exception(__('Please upload a DirectAdmin backup archive or enter its full path.'));
}
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
if (! $backupFullPath) {
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
}
} else {
$remotePassword = $this->remotePassword;
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
$remotePassword = (string) $import->remote_password;
}
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
}
}
$result = $this->getAgent()->importDiscover(
$import->id,
'directadmin',
$import->import_method,
$backupFullPath,
$import->remote_host,
$import->remote_port ? (int) $import->remote_port : null,
$import->remote_user,
$remotePassword,
);
if (! ($result['success'] ?? false)) {
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
}
$accounts = $result['accounts'] ?? [];
if (! is_array($accounts) || $accounts === []) {
throw new Exception(__('No accounts were discovered.'));
}
$import->accounts()->delete();
$createdIds = [];
foreach ($accounts as $account) {
if (! is_array($account)) {
continue;
}
$username = trim((string) ($account['username'] ?? ''));
if ($username === '') {
continue;
}
$record = ServerImportAccount::create([
'server_import_id' => $import->id,
'source_username' => $username,
'target_username' => $username,
'email' => (string) ($account['email'] ?? ''),
'main_domain' => (string) ($account['main_domain'] ?? ''),
'addon_domains' => $account['addon_domains'] ?? [],
'subdomains' => $account['subdomains'] ?? [],
'databases' => $account['databases'] ?? [],
'email_accounts' => $account['email_accounts'] ?? [],
'disk_usage' => (int) ($account['disk_usage'] ?? 0),
'status' => 'pending',
'progress' => 0,
'current_task' => null,
'import_log' => [],
'error' => null,
]);
$createdIds[] = $record->id;
}
if ($createdIds === []) {
throw new Exception(__('No valid accounts were discovered.'));
}
$import->update([
'discovered_accounts' => $accounts,
'selected_accounts' => [],
'status' => 'ready',
'progress' => 0,
'current_task' => null,
'errors' => [],
]);
$this->importId = $import->id;
$this->step1Complete = true;
$this->saveToSession();
$this->dispatch('directadmin-accounts-updated');
Notification::make()
->title(__('Accounts discovered'))
->body(__('Found :count account(s).', ['count' => count($createdIds)]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Discovery failed'))
->body($e->getMessage())
->danger()
->send();
}
}
protected function resolveBackupFullPath(?string $path): ?string
{
$path = trim((string) ($path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
public function selectAllAccounts(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$ids = $import->accounts()->pluck('id')->all();
$import->update(['selected_accounts' => $ids]);
$this->dispatch('directadmin-selection-updated');
}
public function deselectAllAccounts(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$import->update(['selected_accounts' => []]);
$this->dispatch('directadmin-selection-updated');
}
public function refreshAccountsTable(): void
{
$this->dispatch('directadmin-accounts-updated');
$this->dispatch('directadmin-config-updated');
}
public function startMigration(): void
{
$import = $this->getImport();
if (! $import) {
Notification::make()
->title(__('Import job not found'))
->danger()
->send();
return;
}
$selected = $import->selected_accounts ?? [];
if (! is_array($selected) || $selected === []) {
Notification::make()
->title(__('No accounts selected'))
->body(__('Please select at least one account to migrate.'))
->danger()
->send();
return;
}
if ($import->import_method === 'remote_server') {
Notification::make()
->title(__('Remote DirectAdmin import is not available yet'))
->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.'))
->warning()
->send();
return;
}
$import->update([
'status' => 'importing',
'started_at' => now(),
]);
$result = $this->getAgent()->importStart($import->id);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to start migration'))
->body((string) ($result['error'] ?? __('Unknown error')))
->danger()
->send();
return;
}
Notification::make()
->title(__('Migration started'))
->body(__('Import process has started in the background.'))
->success()
->send();
}
public function resetMigration(): void
{
if ($this->importId) {
ServerImport::whereKey($this->importId)->delete();
}
session()->forget('directadmin_migration.import_id');
$this->wizardStep = null;
$this->step1Complete = false;
$this->importId = null;
$this->name = null;
$this->importMethod = 'remote_server';
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->backupPath = null;
$this->backupFilePath = null;
$this->importFiles = true;
$this->importDatabases = true;
$this->importEmails = true;
$this->importSsl = true;
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::with('accounts')->find($this->importId);
}
protected function upsertImportForDiscovery(): ServerImport
{
$name = trim((string) ($this->name ?: ''));
if ($name === '') {
$name = 'DirectAdmin Import '.now()->format('Y-m-d H:i');
}
$attributes = [
'name' => $name,
'source_type' => 'directadmin',
'import_method' => $this->importMethod,
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
'status' => 'discovering',
'progress' => 0,
'current_task' => null,
];
if ($this->importMethod === 'backup_file') {
$backupPath = filled($this->backupFilePath)
? trim((string) $this->backupFilePath)
: $this->backupPath;
$attributes['backup_path'] = $backupPath ?: null;
$attributes['remote_host'] = null;
$attributes['remote_port'] = null;
$attributes['remote_user'] = null;
} else {
$attributes['backup_path'] = null;
$attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null;
$attributes['remote_port'] = $this->remotePort;
$attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null;
if (filled($this->remotePassword)) {
$attributes['remote_password'] = $this->remotePassword;
}
}
$import = $this->importId ? ServerImport::find($this->importId) : null;
if ($import) {
$import->update($attributes);
} else {
$import = ServerImport::create($attributes);
$this->importId = $import->id;
}
$this->saveToSession();
return $import->fresh();
}
protected function getDiscoveredAccountsCount(): int
{
$import = $this->getImport();
return $import ? $import->accounts()->count() : 0;
}
protected function getSelectedAccountsCount(): int
{
$import = $this->getImport();
$selected = $import?->selected_accounts ?? [];
return is_array($selected) ? count($selected) : 0;
}
protected function getAccountsStepDescription(): string
{
$selected = $this->getSelectedAccountsCount();
$total = $this->getDiscoveredAccountsCount();
if ($total === 0) {
return __('No accounts discovered yet.');
}
if ($selected === 0) {
return __(':count accounts discovered', ['count' => $total]);
}
return __(':selected of :count accounts selected', ['selected' => $selected, 'count' => $total]);
}
protected function saveToSession(): void
{
if ($this->importId) {
session()->put('directadmin_migration.import_id', $this->importId);
}
session()->save();
}
protected function restoreFromSession(): void
{
$this->importId = session('directadmin_migration.import_id');
}
protected function restoreFromImport(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$this->name = $import->name;
$this->importMethod = (string) ($import->import_method ?? 'remote_server');
$backupPath = is_string($import->backup_path) ? trim($import->backup_path) : null;
if ($backupPath && str_starts_with($backupPath, '/')) {
$this->backupFilePath = $backupPath;
$this->backupPath = null;
} else {
$this->backupPath = $backupPath;
$this->backupFilePath = null;
}
$this->remoteHost = $import->remote_host;
$this->remotePort = (int) ($import->remote_port ?? 2222);
$this->remoteUser = $import->remote_user;
$options = $import->import_options ?? [];
if (is_array($options)) {
$this->importFiles = (bool) ($options['files'] ?? true);
$this->importDatabases = (bool) ($options['databases'] ?? true);
$this->importEmails = (bool) ($options['emails'] ?? true);
$this->importSsl = (bool) ($options['ssl'] ?? true);
}
$this->step1Complete = $import->accounts()->exists();
}
}

View File

@@ -41,19 +41,19 @@ class Migration extends Page implements HasForms
public function getSubheading(): ?string public function getSubheading(): ?string
{ {
return __('Migrate cPanel accounts directly or via WHM'); return __('Migrate cPanel, WHM, or DirectAdmin accounts into Jabali');
} }
public function mount(): void public function mount(): void
{ {
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) { if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel'; $this->activeTab = 'cpanel';
} }
} }
public function updatedActiveTab(string $activeTab): void public function updatedActiveTab(string $activeTab): void
{ {
if (! in_array($activeTab, ['cpanel', 'whm'], true)) { if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel'; $this->activeTab = 'cpanel';
} }
} }
@@ -79,6 +79,11 @@ class Migration extends Page implements HasForms
->schema([ ->schema([
View::make('filament.admin.pages.migration-whm-tab'), View::make('filament.admin.pages.migration-whm-tab'),
]), ]),
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.admin.pages.migration-directadmin-tab'),
]),
]), ]),
]); ]);
} }

View File

@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
->visible(fn () => $this->fail2banRunning) ->visible(fn () => $this->fail2banRunning)
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(__('Disable Fail2ban')) ->modalHeading(__('Disable Fail2ban'))
->modalDescription(__('Fail2ban will be stopped and disabled. You can re-enable it later from this tab.')) ->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(__('Warning: Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
->action('disableFail2ban'), ->action('disableFail2ban'),
]) ])
->schema([ ->schema([
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
->color(fn () => $this->clamavRunning ? 'warning' : 'success') ->color(fn () => $this->clamavRunning ? 'warning' : 'success')
->size('sm') ->size('sm')
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav()) ->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
->requiresConfirmation(fn () => ! $this->clamavRunning) ->requiresConfirmation()
->modalHeading(fn () => $this->clamavRunning ? __('Disable ClamAV') : __('Enable ClamAV'))
->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn () => $this->clamavRunning ->modalDescription(fn () => $this->clamavRunning
? __('ClamAV will be stopped and disabled. You can re-enable it later.') ? __('Warning: This will stop and disable ClamAV. You can re-enable it later.')
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')), : __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
FormAction::make('updateSignatures') FormAction::make('updateSignatures')
->label(__('Update Signatures')) ->label(__('Update Signatures'))

View File

@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
protected function databaseTabContent(): array protected function databaseTabContent(): array
{ {
return [ return [
Section::make(__('Warning: Changing database settings can impact performance or cause outages'))
->description(__('Apply changes only if you understand their effects, and prefer doing so during maintenance windows.'))
->icon('heroicon-o-exclamation-triangle')
->iconColor('warning')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('Database Tuning')) Section::make(__('Database Tuning'))
->description(__('Adjust MariaDB/MySQL global variables.')) ->description(__('Adjust MariaDB/MySQL global variables.'))
->icon('heroicon-o-circle-stack') ->icon('heroicon-o-circle-stack')
->schema([ ->schema([
Placeholder::make('database_tuning_warning')
->content(new HtmlString(
'<div class="rounded-lg bg-warning-500/10 p-4 text-sm text-warning-700 dark:text-warning-400">'.
'<strong>'.__('Warning:').'</strong> '.
__('Changing database settings can impact performance or cause outages. Apply changes only if you understand their effects, and prefer doing so during maintenance windows.').
'</div>'
)),
EmbeddedTable::make(DatabaseTuningTable::class), EmbeddedTable::make(DatabaseTuningTable::class),
]), ]),
]; ];

View File

@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_active']) ->visible(fn (array $record): bool => $record['is_active'])
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(__('Stop Service')) ->modalHeading(__('Stop Service'))
->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']])) ->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn (array $record): string => __('Warning: This will stop :service and may affect running websites and services. Are you sure you want to continue?', ['service' => $record['name']]))
->modalSubmitActionLabel(__('Stop Service')) ->modalSubmitActionLabel(__('Stop Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')), ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
Action::make('restart') Action::make('restart')
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_enabled']) ->visible(fn (array $record): bool => $record['is_enabled'])
->requiresConfirmation() ->requiresConfirmation()
->modalHeading(__('Disable Service')) ->modalHeading(__('Disable Service'))
->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']])) ->modalIcon('heroicon-o-exclamation-triangle')
->modalIconColor('warning')
->modalDescription(fn (array $record): string => __('Warning: This will disable :service and it will not start automatically on boot. Are you sure you want to continue?', ['service' => $record['name']]))
->modalSubmitActionLabel(__('Disable Service')) ->modalSubmitActionLabel(__('Disable Service'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')), ->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
]) ])

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Contracts\Support\Htmlable;
class Support extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
protected static ?int $navigationSort = 23;
protected static ?string $slug = 'support';
protected string $view = 'filament.admin.pages.support';
public static function getNavigationLabel(): string
{
return __('Support');
}
public function getTitle(): string|Htmlable
{
return __('Support');
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Models\User;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\TextInputColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminAccountConfigTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-config-updated')]
#[On('directadmin-selection-updated')]
public function refreshConfig(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
$ids = $this->getSelectedAccountIds();
if ($ids === []) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->whereIn('id', $ids)
->orderBy('source_username')
->get();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('target_user_exists')
->label(__('User'))
->boolean()
->trueIcon('heroicon-o-exclamation-triangle')
->falseIcon('heroicon-o-user-plus')
->trueColor('warning')
->falseColor('success')
->tooltip(fn (ServerImportAccount $record): string => User::where('username', $record->target_username)->exists()
? __('User exists - migration will restore into the existing account')
: __('New user will be created'))
->getStateUsing(fn (ServerImportAccount $record): bool => User::where('username', $record->target_username)->exists()),
TextColumn::make('source_username')
->label(__('Source'))
->weight('bold'),
TextColumn::make('main_domain')
->label(__('Main Domain'))
->wrap(),
TextInputColumn::make('target_username')
->label(__('Target Username'))
->rules([
'required',
'max:32',
'regex:/^[a-z0-9_]+$/i',
]),
TextInputColumn::make('email')
->label(__('Email'))
->rules([
'nullable',
'email',
'max:255',
]),
TextColumn::make('formatted_disk_usage')
->label(__('Disk'))
->toggleable(),
])
->striped()
->paginated([10, 25, 50])
->defaultPaginationPageOption(10)
->emptyStateHeading(__('No accounts selected'))
->emptyStateDescription(__('Go back and select accounts to migrate.'))
->emptyStateIcon('heroicon-o-user-group');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,155 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminAccountsTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-accounts-updated')]
#[On('directadmin-selection-updated')]
public function refreshAccounts(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->orderBy('source_username')
->get();
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('is_selected')
->label('')
->boolean()
->trueIcon('heroicon-s-check-circle')
->falseIcon('heroicon-o-minus-circle')
->trueColor('primary')
->falseColor('gray')
->size(IconSize::Medium)
->getStateUsing(fn (ServerImportAccount $record): bool => in_array($record->id, $this->getSelectedAccountIds(), true)),
TextColumn::make('source_username')
->label(__('Username'))
->weight('bold')
->searchable(),
TextColumn::make('main_domain')
->label(__('Main Domain'))
->wrap()
->searchable(),
TextColumn::make('email')
->label(__('Email'))
->icon('heroicon-o-envelope')
->toggleable()
->wrap(),
TextColumn::make('formatted_disk_usage')
->label(__('Disk'))
->toggleable(),
])
->recordAction('toggleSelection')
->actions([
Action::make('toggleSelection')
->label(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? __('Deselect') : __('Select'))
->icon(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'heroicon-o-x-mark' : 'heroicon-o-check')
->color(fn (ServerImportAccount $record): string => in_array($record->id, $this->getSelectedAccountIds(), true) ? 'gray' : 'primary')
->action(function (ServerImportAccount $record): void {
$import = $this->getImport();
if (! $import) {
return;
}
$selected = $this->getSelectedAccountIds();
if (in_array($record->id, $selected, true)) {
$selected = array_values(array_diff($selected, [$record->id]));
} else {
$selected[] = $record->id;
$selected = array_values(array_unique($selected));
}
$import->update(['selected_accounts' => $selected]);
$this->dispatch('directadmin-selection-updated');
$this->resetTable();
}),
])
->striped()
->paginated([10, 25, 50])
->defaultPaginationPageOption(25)
->emptyStateHeading(__('No accounts found'))
->emptyStateDescription(__('Discover accounts to see them here.'))
->emptyStateIcon('heroicon-o-user-group')
->poll(null);
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Filament\Admin\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_migration.import_id');
}
#[On('directadmin-selection-updated')]
public function refreshStatus(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return array<int>
*/
protected function getSelectedAccountIds(): array
{
$selected = $this->getImport()?->selected_accounts ?? [];
return array_values(array_filter(array_map('intval', is_array($selected) ? $selected : [])));
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
$ids = $this->getSelectedAccountIds();
if ($ids === []) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->whereIn('id', $ids)
->orderBy('source_username')
->get();
}
protected function shouldPoll(): bool
{
$import = $this->getImport();
if (! $import) {
return false;
}
if (in_array($import->status, ['discovering', 'importing'], true)) {
return true;
}
foreach ($this->getRecords() as $record) {
if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) {
return true;
}
}
return false;
}
protected function getStatusText(string $status): string
{
return match ($status) {
'pending' => __('Waiting...'),
'importing' => __('Importing...'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'skipped' => __('Skipped'),
default => __('Unknown'),
};
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('status_icon')
->label('')
->icon(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'heroicon-o-clock',
'importing' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
'skipped' => 'heroicon-o-minus-circle',
default => 'heroicon-o-question-mark-circle',
})
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
})
->size(IconSize::Small)
->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing'
? ['class' => 'animate-spin']
: []),
TextColumn::make('source_username')
->label(__('Account'))
->weight(FontWeight::Bold)
->searchable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (string $state): string => $this->getStatusText($state))
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
}),
TextColumn::make('current_task')
->label(__('Current Task'))
->wrap()
->limit(80)
->default(__('Waiting...')),
TextColumn::make('progress')
->label(__('Progress'))
->suffix('%')
->toggleable(),
])
->striped()
->paginated(false)
->poll($this->shouldPoll() ? '3s' : null)
->emptyStateHeading(__('No selected accounts'))
->emptyStateDescription(__('Select accounts and start migration.'))
->emptyStateIcon('heroicon-o-queue-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
protected static ?string $navigationLabel = null; protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
{ {
return __('cPanel Migration'); return __('cPanel Migration');

View File

@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
use App\Filament\Jabali\Widgets\MailboxesWidget; use App\Filament\Jabali\Widgets\MailboxesWidget;
use App\Filament\Jabali\Widgets\RecentBackupsWidget; use App\Filament\Jabali\Widgets\RecentBackupsWidget;
use App\Filament\Jabali\Widgets\StatsOverview; use App\Filament\Jabali\Widgets\StatsOverview;
use App\Models\DnsSetting;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action;
use Filament\Pages\Dashboard as BaseDashboard; use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
class Dashboard extends BaseDashboard class Dashboard extends BaseDashboard
@@ -41,6 +46,75 @@ class Dashboard extends BaseDashboard
]; ];
} }
public function mount(): void
{
if (! DnsSetting::get('user_onboarding_completed_'.(string) Auth::id(), false)) {
$this->defaultAction = 'onboarding';
}
}
protected function getHeaderActions(): array
{
return [
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Here is a quick path to launch your first site.'))
->modalWidth('2xl')
->form([
Section::make(__('Next Steps'))
->description(__('Follow these steps to get online quickly.'))
->icon('heroicon-o-check-circle')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact()
->schema([
Grid::make(['default' => 1, 'md' => 2])
->schema([
Section::make(__('1. Add a Domain'))
->description(__('Point your DNS to this server or update nameservers.'))
->icon('heroicon-o-globe-alt')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('2. Issue SSL'))
->description(__('Enable HTTPS for your site with SSL certificates.'))
->icon('heroicon-o-lock-closed')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('3. Upload or Install'))
->description(__('Upload files or install WordPress to deploy your site.'))
->icon('heroicon-o-arrow-up-tray')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
Section::make(__('4. Create Email & Databases'))
->description(__('Set up mailboxes and databases for your app.'))
->icon('heroicon-o-envelope')
->iconColor('info')
->collapsed(false)
->collapsible(false)
->compact(),
]),
Text::make(__('Optional: configure backups, cron jobs, and SSH keys for day-to-day operations.'))
->color('gray'),
]),
])
->modalSubmitActionLabel(__("Don't show again"))
->action(function (): void {
DnsSetting::set('user_onboarding_completed_'.(string) Auth::id(), '1');
DnsSetting::clearCache();
}),
];
}
public function getSubheading(): ?string public function getSubheading(): ?string
{ {
$user = Auth::user(); $user = Auth::user();

View File

@@ -0,0 +1,955 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use App\Services\Agent\AgentClient;
use BackedEnum;
use Exception;
use Filament\Actions\Action;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Radio;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Actions as FormActions;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\Url;
class DirectAdminMigration extends Page implements HasActions, HasForms
{
use InteractsWithActions;
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
protected static ?int $navigationSort = 16;
protected static ?string $slug = 'directadmin-migration';
protected string $view = 'filament.jabali.pages.directadmin-migration';
#[Url(as: 'directadmin-step')]
public ?string $wizardStep = null;
public bool $step1Complete = false;
public ?int $importId = null;
public string $importMethod = 'backup_file'; // remote_server|backup_file
public ?string $remoteHost = null;
public int $remotePort = 2222;
public ?string $remoteUser = null;
public ?string $remotePassword = null;
public ?string $localBackupPath = null;
public array $availableBackups = [];
public ?string $backupPath = null;
public bool $importFiles = true;
public bool $importDatabases = true;
public bool $importEmails = true;
public bool $importSsl = true;
protected ?AgentClient $agent = null;
public static function getNavigationLabel(): string
{
return __('DirectAdmin Migration');
}
public function getTitle(): string|Htmlable
{
return __('DirectAdmin Migration');
}
public function getSubheading(): ?string
{
return __('Migrate your DirectAdmin account into your Jabali account');
}
protected function getHeaderActions(): array
{
return [
Action::make('startOver')
->label(__('Start Over'))
->icon('heroicon-o-arrow-path')
->color('gray')
->requiresConfirmation()
->modalHeading(__('Start Over'))
->modalDescription(__('This will reset the DirectAdmin migration wizard. Are you sure?'))
->action('resetMigration'),
];
}
public function mount(): void
{
$this->restoreFromSession();
$this->restoreFromImport();
if ($this->importMethod === 'backup_file') {
$this->loadLocalBackups();
}
}
public function updatedImportMethod(): void
{
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->localBackupPath = null;
$this->backupPath = null;
$this->availableBackups = [];
if ($this->importMethod === 'backup_file') {
$this->loadLocalBackups();
}
}
public function updatedLocalBackupPath(): void
{
if (! $this->localBackupPath) {
$this->backupPath = null;
return;
}
$this->selectLocalBackup();
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Wizard::make([
$this->getConnectStep(),
$this->getConfigureStep(),
$this->getMigrateStep(),
])
->persistStepInQueryString('directadmin-step'),
]);
}
protected function getConnectStep(): Step
{
return Step::make(__('Connect'))
->id('connect')
->icon('heroicon-o-link')
->description(__('Connect to DirectAdmin or upload a backup'))
->schema([
Section::make(__('Source'))
->description(__('For now, migration requires a DirectAdmin backup archive. Remote migration will be added next.'))
->icon('heroicon-o-server')
->schema([
Radio::make('importMethod')
->label(__('Import Method'))
->options([
'backup_file' => __('Backup File'),
'remote_server' => __('Remote Server (Discovery only)'),
])
->default('backup_file')
->live(),
Grid::make(['default' => 1, 'sm' => 2])
->schema([
TextInput::make('remoteHost')
->label(__('Host'))
->placeholder('directadmin.example.com')
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePort')
->label(__('Port'))
->numeric()
->default(2222)
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remoteUser')
->label(__('Username'))
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
TextInput::make('remotePassword')
->label(__('Password'))
->password()
->revealable()
->required()
->visible(fn (Get $get): bool => $get('importMethod') === 'remote_server'),
]),
Section::make(__('Backup File'))
->description(__('Upload your DirectAdmin backup archive to your backups folder, then select it here.'))
->icon('heroicon-o-folder')
->visible(fn (Get $get): bool => $get('importMethod') === 'backup_file')
->headerActions([
Action::make('uploadBackup')
->label(__('Upload'))
->icon('heroicon-o-arrow-up-tray')
->color('gray')
->modalHeading(__('Upload Backup'))
->modalDescription(fn (): string => ($user = $this->getUser())
? __('Upload a DirectAdmin backup archive into /home/:user/backups', ['user' => $user->username])
: __('Upload a DirectAdmin backup archive into your backups folder'))
->modalSubmitActionLabel(__('Upload'))
->form([
FileUpload::make('backup')
->label(__('DirectAdmin Backup Archive'))
->storeFiles(false)
->required()
->maxSize(512000) // 500MB in KB
->helperText(__('Supported formats: .tar.zst, .tar.gz, .tgz (max 500MB via upload)')),
])
->action(function (array $data): void {
try {
$user = $this->getUser();
if (! $user) {
throw new Exception(__('You must be logged in.'));
}
$file = $data['backup'] ?? null;
if (! $file) {
throw new Exception(__('Please select a backup file.'));
}
$filename = (string) $file->getClientOriginalName();
$filename = basename($filename);
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $filename)) {
throw new Exception(__('Backup must be a .zst, .tar.zst, .tar.gz or .tgz file.'));
}
$maxBytes = 500 * 1024 * 1024;
$fileSize = (int) ($file->getSize() ?? 0);
if ($fileSize > $maxBytes) {
throw new Exception(__('File too large for upload (max 500MB). Upload it via SSH/SFTP to /home/:user/backups.', [
'user' => $user->username,
]));
}
// Ensure backups folder exists (mkdir will error if it already exists).
try {
$this->getAgent()->fileMkdir($user->username, 'backups');
} catch (Exception $e) {
if ($e->getMessage() !== 'Path already exists') {
throw $e;
}
}
// Stage into the agent-allowed temp dir, then let the agent move it.
$tmpDir = '/tmp/jabali-uploads';
if (! is_dir($tmpDir)) {
mkdir($tmpDir, 0700, true);
chmod($tmpDir, 0700);
} else {
@chmod($tmpDir, 0700);
}
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', $filename);
$tmpPath = $tmpDir.'/'.uniqid('upload_', true).'_'.$safeName;
if (! @copy($file->getRealPath(), $tmpPath)) {
throw new Exception(__('Failed to stage upload.'));
}
@chmod($tmpPath, 0600);
$result = $this->getAgent()->send('file.upload_temp', [
'username' => $user->username,
'path' => 'backups',
'filename' => $safeName,
'temp_path' => $tmpPath,
]);
if (! ($result['success'] ?? false)) {
if (file_exists($tmpPath)) {
@unlink($tmpPath);
}
throw new Exception((string) ($result['error'] ?? __('Upload failed')));
}
$this->loadLocalBackups();
$uploadedPath = $result['path'] ?? null;
if (is_string($uploadedPath) && $uploadedPath !== '') {
$this->localBackupPath = $uploadedPath;
$this->selectLocalBackup();
}
Notification::make()
->title(__('Backup uploaded'))
->body(__('Uploaded :name', ['name' => $safeName]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Upload failed'))
->body($e->getMessage())
->danger()
->send();
}
}),
Action::make('refreshLocalBackups')
->label(__('Refresh'))
->icon('heroicon-o-arrow-path')
->color('gray')
->action('refreshLocalBackups'),
])
->schema([
Select::make('localBackupPath')
->label(__('Backup File'))
->options(fn (): array => $this->getLocalBackupOptions())
->searchable()
->required(fn (Get $get): bool => $get('importMethod') === 'backup_file')
->live(),
Text::make(fn (): string => $this->backupPath
? __('Selected file: :file', ['file' => basename($this->backupPath)])
: __('No backup selected yet.'))
->color('gray'),
Text::make(fn (): string => ($user = $this->getUser())
? __('Upload the file to: /home/:user/backups', ['user' => $user->username])
: __('Upload the file to your /home/<user>/backups folder.'))
->color('gray'),
Text::make(__('Supported formats: .tar.zst, .tar.gz, .tgz'))->color('gray'),
Text::make(fn (): string => ($user = $this->getUser())
? __('No backups found in /home/:user/backups. Upload a file there and click Refresh.', ['user' => $user->username])
: __('No backups found.'))
->color('gray')
->visible(fn (): bool => empty($this->availableBackups)),
]),
FormActions::make([
Action::make('discoverAccount')
->label(__('Discover Account'))
->icon('heroicon-o-magnifying-glass')
->color('primary')
->action('discoverAccount'),
])->alignEnd(),
]),
Section::make(__('Discovery'))
->description(__('After discovery, you can choose what to import.'))
->icon('heroicon-o-user')
->schema([
Text::make(__('Discovered account details will be used for migration.'))->color('gray'),
]),
])
->afterValidation(function () {
$import = $this->getImport();
$hasAccounts = $import?->accounts()->exists() ?? false;
if (! $hasAccounts) {
Notification::make()
->title(__('No account discovered'))
->body(__('Click "Discover Account" to continue.'))
->danger()
->send();
throw new Exception(__('No account discovered'));
}
$this->step1Complete = true;
$this->saveToSession();
});
}
protected function getConfigureStep(): Step
{
return Step::make(__('Configure'))
->id('configure')
->icon('heroicon-o-cog')
->description(__('Choose what to import'))
->schema([
Section::make(__('What to Import'))
->description(__('Select which parts of your account to import.'))
->icon('heroicon-o-check-circle')
->schema([
Grid::make(['default' => 1, 'sm' => 2])->schema([
Checkbox::make('importFiles')
->label(__('Website Files'))
->helperText(__('Restore website files from the backup'))
->default(true),
Checkbox::make('importDatabases')
->label(__('Databases'))
->helperText(__('Restore MySQL databases and import dumps'))
->default(true),
Checkbox::make('importEmails')
->label(__('Email'))
->helperText(__('Create email domains and mailboxes (limited in Phase 1)'))
->default(true),
Checkbox::make('importSsl')
->label(__('SSL'))
->helperText(__('Install custom certificates or issue Let\'s Encrypt (Phase 3)'))
->default(true),
]),
]),
])
->afterValidation(function (): void {
$import = $this->getImport();
if (! $import) {
throw new Exception(__('Import job not found'));
}
$import->update([
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
]);
$this->saveToSession();
});
}
protected function getMigrateStep(): Step
{
return Step::make(__('Migrate'))
->id('migrate')
->icon('heroicon-o-play')
->description(__('Run the migration and watch progress'))
->schema([
FormActions::make([
Action::make('startMigration')
->label(__('Start Migration'))
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading(__('Start Migration'))
->modalDescription(__('This will import data into your Jabali account. Continue?'))
->action('startMigration'),
Action::make('newMigration')
->label(__('New Migration'))
->icon('heroicon-o-plus')
->color('primary')
->visible(fn (): bool => ($this->getImport()?->status ?? null) === 'completed')
->action('resetMigration'),
])->alignEnd(),
Section::make(__('Import Status'))
->icon('heroicon-o-queue-list')
->schema([
View::make('filament.jabali.pages.directadmin-migration-status-table'),
]),
]);
}
public function discoverAccount(): void
{
try {
$user = Auth::user();
if (! $user) {
throw new Exception(__('You must be logged in.'));
}
$import = $this->upsertImportForDiscovery();
$backupFullPath = null;
$remotePassword = null;
if ($this->importMethod === 'backup_file') {
if (! $import->backup_path) {
throw new Exception(__('Please select a DirectAdmin backup archive.'));
}
$backupFullPath = $this->resolveBackupFullPath($import->backup_path);
if (! $backupFullPath) {
throw new Exception(__('Backup file not found: :path', ['path' => $import->backup_path]));
}
} else {
$remotePassword = $this->remotePassword;
if (($remotePassword === null || $remotePassword === '') && filled($import->remote_password)) {
$remotePassword = (string) $import->remote_password;
}
if (! $import->remote_host || ! $import->remote_port || ! $import->remote_user || ! $remotePassword) {
throw new Exception(__('Please enter DirectAdmin host, port, username and password.'));
}
}
$result = $this->getAgent()->importDiscover(
$import->id,
'directadmin',
$import->import_method,
$backupFullPath,
$import->remote_host,
$import->remote_port ? (int) $import->remote_port : null,
$import->remote_user,
$remotePassword,
);
if (! ($result['success'] ?? false)) {
throw new Exception((string) ($result['error'] ?? __('Discovery failed')));
}
$accounts = $result['accounts'] ?? [];
if (! is_array($accounts) || $accounts === []) {
throw new Exception(__('No account was discovered.'));
}
$account = null;
if (count($accounts) === 1) {
$account = $accounts[0];
} else {
// Prefer matching the provided username if multiple accounts are returned.
foreach ($accounts as $candidate) {
if (! is_array($candidate)) {
continue;
}
if (($candidate['username'] ?? null) === $this->remoteUser) {
$account = $candidate;
break;
}
}
}
if (! is_array($account)) {
throw new Exception(__('Multiple accounts were discovered. Please upload a single-user backup archive.'));
}
$sourceUsername = trim((string) ($account['username'] ?? ''));
if ($sourceUsername === '') {
throw new Exception(__('Discovered account is missing a username.'));
}
$import->accounts()->delete();
$record = ServerImportAccount::create([
'server_import_id' => $import->id,
'source_username' => $sourceUsername,
'target_username' => $user->username,
'email' => (string) ($account['email'] ?? ''),
'main_domain' => (string) ($account['main_domain'] ?? ''),
'addon_domains' => $account['addon_domains'] ?? [],
'subdomains' => $account['subdomains'] ?? [],
'databases' => $account['databases'] ?? [],
'email_accounts' => $account['email_accounts'] ?? [],
'disk_usage' => (int) ($account['disk_usage'] ?? 0),
'status' => 'pending',
'progress' => 0,
'current_task' => null,
'import_log' => [],
'error' => null,
]);
$import->update([
'discovered_accounts' => [$account],
'selected_accounts' => [$record->id],
'status' => 'ready',
'progress' => 0,
'current_task' => null,
'errors' => [],
]);
$this->importId = $import->id;
$this->step1Complete = true;
$this->saveToSession();
$this->dispatch('directadmin-self-status-updated');
Notification::make()
->title(__('Account discovered'))
->body(__('Ready to migrate into your Jabali account (:username).', ['username' => $user->username]))
->success()
->send();
} catch (Exception $e) {
Notification::make()
->title(__('Discovery failed'))
->body($e->getMessage())
->danger()
->send();
}
}
public function startMigration(): void
{
$import = $this->getImport();
if (! $import) {
Notification::make()
->title(__('Import job not found'))
->danger()
->send();
return;
}
$selected = $import->selected_accounts ?? [];
if (! is_array($selected) || $selected === []) {
Notification::make()
->title(__('No account selected'))
->danger()
->send();
return;
}
if ($import->import_method === 'remote_server') {
Notification::make()
->title(__('Remote DirectAdmin import is not available yet'))
->body(__('For now, please download a DirectAdmin backup archive and use the "Backup File" method.'))
->warning()
->send();
return;
}
$import->update([
'status' => 'importing',
'started_at' => now(),
]);
$result = $this->getAgent()->importStart($import->id);
if (! ($result['success'] ?? false)) {
Notification::make()
->title(__('Failed to start migration'))
->body((string) ($result['error'] ?? __('Unknown error')))
->danger()
->send();
return;
}
Notification::make()
->title(__('Migration started'))
->body(__('Import process has started in the background.'))
->success()
->send();
$this->dispatch('directadmin-self-status-updated');
}
public function resetMigration(): void
{
if ($this->importId) {
ServerImport::whereKey($this->importId)->delete();
}
session()->forget('directadmin_self_migration.import_id');
$this->wizardStep = null;
$this->step1Complete = false;
$this->importId = null;
$this->importMethod = 'backup_file';
$this->remoteHost = null;
$this->remotePort = 2222;
$this->remoteUser = null;
$this->remotePassword = null;
$this->localBackupPath = null;
$this->availableBackups = [];
$this->backupPath = null;
$this->importFiles = true;
$this->importDatabases = true;
$this->importEmails = true;
$this->importSsl = true;
}
protected function getAgent(): AgentClient
{
return $this->agent ??= new AgentClient;
}
protected function getUser()
{
return Auth::user();
}
protected function loadLocalBackups(): void
{
$this->availableBackups = [];
$user = $this->getUser();
if (! $user) {
return;
}
$result = $this->getAgent()->send('file.list', [
'username' => $user->username,
'path' => 'backups',
]);
if (! ($result['success'] ?? false)) {
$this->getAgent()->send('file.mkdir', [
'username' => $user->username,
'path' => 'backups',
]);
$result = $this->getAgent()->send('file.list', [
'username' => $user->username,
'path' => 'backups',
]);
if (! ($result['success'] ?? false)) {
return;
}
}
$items = $result['items'] ?? [];
foreach ($items as $item) {
if (($item['is_dir'] ?? false) === true) {
continue;
}
$name = (string) ($item['name'] ?? '');
if (! preg_match('/\\.(tar\\.zst|zst|tar\\.gz|tgz)$/i', $name)) {
continue;
}
$this->availableBackups[] = $item;
}
}
public function refreshLocalBackups(): void
{
$this->loadLocalBackups();
Notification::make()
->title(__('Backup list refreshed'))
->success()
->send();
}
protected function getLocalBackupOptions(): array
{
$options = [];
foreach ($this->availableBackups as $item) {
$path = $item['path'] ?? null;
$name = $item['name'] ?? null;
if (! $path || ! $name) {
continue;
}
$size = $this->formatBytes((int) ($item['size'] ?? 0));
$options[$path] = "{$name} ({$size})";
}
return $options;
}
protected function selectLocalBackup(): void
{
$user = $this->getUser();
if (! $user || ! $this->localBackupPath) {
return;
}
$info = $this->getAgent()->send('file.info', [
'username' => $user->username,
'path' => $this->localBackupPath,
]);
if (! ($info['success'] ?? false)) {
Notification::make()
->title(__('Backup file not found'))
->body($info['error'] ?? __('Unable to read backup file'))
->danger()
->send();
$this->backupPath = null;
return;
}
$details = $info['info'] ?? [];
if (! ($details['is_file'] ?? false)) {
Notification::make()
->title(__('Invalid backup selection'))
->body(__('Please select a backup file'))
->warning()
->send();
$this->backupPath = null;
return;
}
$this->backupPath = "/home/{$user->username}/{$this->localBackupPath}";
Notification::make()
->title(__('Backup selected'))
->body(__('Selected :name (:size)', [
'name' => $details['name'] ?? basename($this->backupPath),
'size' => $this->formatBytes((int) ($details['size'] ?? 0)),
]))
->success()
->send();
}
protected function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) {
return number_format($bytes / 1073741824, 2).' GB';
}
if ($bytes >= 1048576) {
return number_format($bytes / 1048576, 2).' MB';
}
if ($bytes >= 1024) {
return number_format($bytes / 1024, 2).' KB';
}
return $bytes.' B';
}
protected function resolveBackupFullPath(?string $path): ?string
{
$path = trim((string) ($path ?? ''));
if ($path === '') {
return null;
}
if (str_starts_with($path, '/') && file_exists($path)) {
return $path;
}
$localCandidate = Storage::disk('local')->path($path);
if (file_exists($localCandidate)) {
return $localCandidate;
}
$backupCandidate = Storage::disk('backups')->path($path);
if (file_exists($backupCandidate)) {
return $backupCandidate;
}
return file_exists($path) ? $path : null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::with('accounts')->find($this->importId);
}
protected function upsertImportForDiscovery(): ServerImport
{
$user = Auth::user();
$name = $user ? ('DirectAdmin Import - '.$user->username.' - '.now()->format('Y-m-d H:i')) : ('DirectAdmin Import '.now()->format('Y-m-d H:i'));
$attributes = [
'name' => $name,
'source_type' => 'directadmin',
'import_method' => $this->importMethod,
'import_options' => [
'files' => $this->importFiles,
'databases' => $this->importDatabases,
'emails' => $this->importEmails,
'ssl' => $this->importSsl,
],
'status' => 'discovering',
'progress' => 0,
'current_task' => null,
];
if ($this->importMethod === 'backup_file') {
$attributes['backup_path'] = $this->backupPath;
$attributes['remote_host'] = null;
$attributes['remote_port'] = null;
$attributes['remote_user'] = null;
} else {
$attributes['backup_path'] = null;
$attributes['remote_host'] = $this->remoteHost ? trim($this->remoteHost) : null;
$attributes['remote_port'] = $this->remotePort;
$attributes['remote_user'] = $this->remoteUser ? trim($this->remoteUser) : null;
if (filled($this->remotePassword)) {
$attributes['remote_password'] = $this->remotePassword;
}
}
$import = $this->importId ? ServerImport::find($this->importId) : null;
if ($import) {
$import->update($attributes);
} else {
$import = ServerImport::create($attributes);
$this->importId = $import->id;
}
$this->saveToSession();
return $import->fresh();
}
protected function saveToSession(): void
{
if ($this->importId) {
session()->put('directadmin_self_migration.import_id', $this->importId);
}
session()->save();
}
protected function restoreFromSession(): void
{
$this->importId = session('directadmin_self_migration.import_id');
}
protected function restoreFromImport(): void
{
$import = $this->getImport();
if (! $import) {
return;
}
$this->importMethod = (string) ($import->import_method ?? 'backup_file');
$this->backupPath = $import->backup_path;
if ($this->backupPath && ($user = $this->getUser())) {
$prefix = "/home/{$user->username}/";
if (str_starts_with($this->backupPath, $prefix)) {
$this->localBackupPath = ltrim(substr($this->backupPath, strlen($prefix)), '/');
}
}
$this->remoteHost = $import->remote_host;
$this->remotePort = (int) ($import->remote_port ?? 2222);
$this->remoteUser = $import->remote_user;
$options = $import->import_options ?? [];
if (is_array($options)) {
$this->importFiles = (bool) ($options['files'] ?? true);
$this->importDatabases = (bool) ($options['databases'] ?? true);
$this->importEmails = (bool) ($options['emails'] ?? true);
$this->importSsl = (bool) ($options['ssl'] ?? true);
}
$this->step1Complete = $import->accounts()->exists();
}
}

View File

@@ -32,6 +32,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View; use Filament\Schemas\Components\View;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain')) ->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required() ->required()
->searchable(), ->searchable()
->live()
->live(),
TextInput::make('local_part') TextInput::make('local_part')
->label(__('Email Address')) ->label(__('Email Address'))
->required() ->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/') ->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64) ->maxLength(64)
->helperText(__('The part before the @ symbol')), ->helperText(__('The part before the @ symbol')),
TextInput::make('name') TextInput::make('name')
->label(__('Display Name')) ->label(__('Display Name'))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->maxLength(255), ->maxLength(255),
TextInput::make('password') TextInput::make('password')
->label(__('Password')) ->label(__('Password'))
->password() ->password()
->revealable() ->revealable()
->required() ->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->minLength(8) ->minLength(8)
->rules([ ->rules([
'regex:/[a-z]/', // lowercase 'regex:/[a-z]/', // lowercase
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
TextInput::make('quota_mb') TextInput::make('quota_mb')
->label(__('Quota (MB)')) ->label(__('Quota (MB)'))
->numeric() ->numeric()
->visible(fn (Get $get): bool => filled($get('domain_id')))
->default(1024) ->default(1024)
->minValue(100) ->minValue(100)
->maxValue(10240) ->maxValue(10240)
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain')) ->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray()) ->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required() ->required()
->searchable(), ->searchable()
->live(),
TextInput::make('local_part') TextInput::make('local_part')
->label(__('Email Address')) ->label(__('Email Address'))
->required() ->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->regex('/^[a-zA-Z0-9._%+-]+$/') ->regex('/^[a-zA-Z0-9._%+-]+$/')
->maxLength(64) ->maxLength(64)
->helperText(__('The part before the @ symbol')), ->helperText(__('The part before the @ symbol')),
TextInput::make('destinations') TextInput::make('destinations')
->label(__('Forward To')) ->label(__('Forward To'))
->required() ->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->helperText(__('Comma-separated email addresses to forward to')), ->helperText(__('Comma-separated email addresses to forward to')),
]) ])
->action(function (array $data): void { ->action(function (array $data): void {

View File

@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
public function getTitle(): string|Htmlable public function getTitle(): string|Htmlable
{ {
return 'File Manager'; return __('File Manager');
} }
public function getAgent(): AgentClient public function getAgent(): AgentClient

View File

@@ -83,7 +83,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
protected function getWebhookUrl(GitDeploymentModel $deployment): string protected function getWebhookUrl(GitDeploymentModel $deployment): string
{ {
return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}"); return url("/api/webhooks/git/{$deployment->id}");
} }
protected function getDeployKey(): string protected function getDeployKey(): string
@@ -162,6 +162,11 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
->rows(2) ->rows(2)
->disabled() ->disabled()
->dehydrated(false), ->dehydrated(false),
TextInput::make('webhook_secret')
->label(__('Webhook Secret'))
->helperText(__('Set this as your provider webhook secret. Jabali validates HMAC-SHA256 signatures.'))
->disabled()
->dehydrated(false),
Textarea::make('deploy_key') Textarea::make('deploy_key')
->label(__('Deploy Key')) ->label(__('Deploy Key'))
->rows(3) ->rows(3)
@@ -170,6 +175,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
]) ])
->fillForm(fn (GitDeploymentModel $record): array => [ ->fillForm(fn (GitDeploymentModel $record): array => [
'webhook_url' => $this->getWebhookUrl($record), 'webhook_url' => $this->getWebhookUrl($record),
'webhook_secret' => $record->secret_token,
'deploy_key' => $this->getDeployKey(), 'deploy_key' => $this->getDeployKey(),
]), ]),
Action::make('edit') Action::make('edit')

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages; namespace App\Filament\Jabali\Pages;
use App\Models\AuditLog; use App\Filament\Jabali\Widgets\ActivityLogTable;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -16,6 +16,7 @@ use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Tabs; use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab; use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\View; use Filament\Schemas\Components\View;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable; use Illuminate\Contracts\Support\Htmlable;
@@ -119,7 +120,7 @@ class Logs extends Page implements HasActions, HasForms
'activity' => Tab::make(__('Activity Log')) 'activity' => Tab::make(__('Activity Log'))
->icon('heroicon-o-clipboard-document-list') ->icon('heroicon-o-clipboard-document-list')
->schema([ ->schema([
View::make('filament.jabali.pages.logs-tab-activity'), EmbeddedTable::make(ActivityLogTable::class),
]), ]),
]), ]),
]); ]);
@@ -227,15 +228,6 @@ class Logs extends Page implements HasActions, HasForms
->send(); ->send();
} }
public function getActivityLogs()
{
return AuditLog::query()
->where('user_id', Auth::id())
->latest()
->limit(50)
->get();
}
public function generateStats(): void public function generateStats(): void
{ {
if (! $this->selectedDomain) { if (! $this->selectedDomain) {

View File

@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use BackedEnum;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Illuminate\Contracts\Support\Htmlable;
use Livewire\Attributes\Url;
class Migration extends Page implements HasForms
{
use InteractsWithForms;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrow-down-tray';
protected static ?string $navigationLabel = null;
protected static ?int $navigationSort = 15;
protected string $view = 'filament.jabali.pages.migration';
#[Url(as: 'migration')]
public string $activeTab = 'cpanel';
public static function getNavigationLabel(): string
{
return __('Migration');
}
public function getTitle(): string|Htmlable
{
return __('Migration');
}
public function getSubheading(): ?string
{
return __('Migrate a cPanel or DirectAdmin account into your Jabali account');
}
public function mount(): void
{
if (! in_array($this->activeTab, ['cpanel', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
public function updatedActiveTab(string $activeTab): void
{
if (! in_array($activeTab, ['cpanel', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
protected function getForms(): array
{
return ['migrationForm'];
}
public function migrationForm(Schema $schema): Schema
{
return $schema->schema([
Tabs::make(__('Migration Type'))
->livewireProperty('activeTab')
->tabs([
'cpanel' => Tabs\Tab::make(__('cPanel Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.jabali.pages.migration-cpanel-tab'),
]),
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
->icon('heroicon-o-arrow-down-tray')
->schema([
View::make('filament.jabali.pages.migration-directadmin-tab'),
]),
]),
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use BackedEnum;
use Filament\Pages\Page;
use Illuminate\Contracts\Support\Htmlable;
class Support extends Page
{
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
protected static ?int $navigationSort = 23;
protected static ?string $slug = 'support';
protected string $view = 'filament.jabali.pages.support';
public static function getNavigationLabel(): string
{
return __('Support');
}
public function getTitle(): string|Htmlable
{
return __('Support');
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages; namespace App\Filament\Jabali\Pages;
use App\Models\Domain; use App\Models\Domain;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\MysqlCredential; use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient; use App\Services\Agent\AgentClient;
use BackedEnum; use BackedEnum;
@@ -22,6 +24,7 @@ use Filament\Infolists\Components\TextEntry;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ViewColumn; use Filament\Tables\Columns\ViewColumn;
use Filament\Tables\Concerns\InteractsWithTable; use Filament\Tables\Concerns\InteractsWithTable;
@@ -204,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->modalDescription(__('This will create a copy of your site for testing.')) ->modalDescription(__('This will create a copy of your site for testing.'))
->modalIcon('heroicon-o-document-duplicate') ->modalIcon('heroicon-o-document-duplicate')
->modalIconColor('info') ->modalIconColor('info')
->form([ ->form(function (array $record): array {
$sourceDomain = strtolower(trim((string) ($record['domain'] ?? '')));
$ownedDomainOptions = $this->getOwnedDomainOptions([$sourceDomain]);
return [
Select::make('staging_target_type')
->label(__('Target Type'))
->options([
'subdomain' => __('Subdomain (on source domain)'),
'domain' => __('Existing domain from my list'),
])
->default('subdomain')
->required()
->native(false)
->live(),
TextInput::make('staging_subdomain') TextInput::make('staging_subdomain')
->label(__('Staging Subdomain')) ->label(__('Subdomain'))
->prefix('staging-')
->suffix(fn (array $record): string => '.'.($record['domain'] ?? '')) ->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test') ->default('test')
->required() ->required(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
->alphaNum(), ->visible(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
]) ->regex('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/')
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])), ->helperText(__('Example: "test" creates test.:domain', ['domain' => $record['domain'] ?? ''])),
Select::make('staging_domain')
->label(__('Target Domain'))
->options($ownedDomainOptions)
->required(fn (Get $get): bool => $get('staging_target_type') === 'domain')
->visible(fn (Get $get): bool => $get('staging_target_type') === 'domain')
->searchable()
->native(false)
->placeholder(__('Select a domain...'))
->helperText(__('Use one of your existing domains as the staging target.')),
];
})
->action(fn (array $data, array $record) => $this->createStaging(
$record['id'],
(string) ($data['staging_subdomain'] ?? ''),
(string) ($data['staging_domain'] ?? ''),
(string) ($data['staging_target_type'] ?? 'subdomain')
)),
Action::make('pushStaging') Action::make('pushStaging')
->label(__('Push to Production')) ->label(__('Push to Production'))
->icon('heroicon-o-arrow-up-tray') ->icon('heroicon-o-arrow-up-tray')
@@ -258,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
); );
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
if (($record['is_staging'] ?? false)) {
$this->removeStagingDnsRecords($record);
}
if (($record['is_staging'] ?? false) && ! empty($record['domain'])) {
Domain::query()
->where('user_id', Auth::id())
->where('domain', (string) $record['domain'])
->delete();
}
// Delete screenshot if exists // Delete screenshot if exists
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png'); $screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) { if (file_exists($screenshotPath)) {
@@ -448,24 +492,29 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->options($domainOptions) ->options($domainOptions)
->required() ->required()
->searchable() ->searchable()
->live()
->placeholder(__('Select a domain...')) ->placeholder(__('Select a domain...'))
->helperText(__('The domain where WordPress will be installed')), ->helperText(__('The domain where WordPress will be installed')),
Toggle::make('use_www') Toggle::make('use_www')
->label(__('Use www prefix')) ->label(__('Use www prefix'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install on www.domain.com instead of domain.com')) ->helperText(__('Install on www.domain.com instead of domain.com'))
->default(false), ->default(false),
TextInput::make('path') TextInput::make('path')
->label(__('Directory (optional)')) ->label(__('Directory (optional)'))
->visible(fn (Get $get): bool => filled($get('domain')))
->placeholder(__('Leave empty to install in root')) ->placeholder(__('Leave empty to install in root'))
->helperText(__('e.g., "blog" to install at domain.com/blog')), ->helperText(__('e.g., "blog" to install at domain.com/blog')),
TextInput::make('site_title') TextInput::make('site_title')
->label(__('Site Title')) ->label(__('Site Title'))
->required() ->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default(__('My WordPress Site')) ->default(__('My WordPress Site'))
->helperText(__('The name of your WordPress site')), ->helperText(__('The name of your WordPress site')),
TextInput::make('admin_user') TextInput::make('admin_user')
->label(__('Admin Username')) ->label(__('Admin Username'))
->required() ->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default('admin') ->default('admin')
->alphaNum() ->alphaNum()
->helperText(__('Username for the WordPress admin account')), ->helperText(__('Username for the WordPress admin account')),
@@ -473,7 +522,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->label(__('Admin Password')) ->label(__('Admin Password'))
->password() ->password()
->revealable() ->revealable()
->required() ->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->default(fn () => $this->generateSecurePassword()) ->default(fn () => $this->generateSecurePassword())
->minLength(8) ->minLength(8)
->rules([ ->rules([
@@ -504,7 +554,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')), ->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
TextInput::make('admin_email') TextInput::make('admin_email')
->label(__('Admin Email')) ->label(__('Admin Email'))
->required() ->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->email() ->email()
->default(Auth::user()->email ?? '') ->default(Auth::user()->email ?? '')
->helperText(__('Email address for the WordPress admin account')), ->helperText(__('Email address for the WordPress admin account')),
@@ -538,14 +589,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
]) ])
->default('en_US') ->default('en_US')
->searchable() ->searchable()
->required() ->required(fn (Get $get): bool => filled($get('domain')))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Default language for WordPress admin and content')), ->helperText(__('Default language for WordPress admin and content')),
Toggle::make('enable_cache') Toggle::make('enable_cache')
->label(__('Enable Jabali Cache')) ->label(__('Enable Jabali Cache'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Install Redis object caching for better performance')) ->helperText(__('Install Redis object caching for better performance'))
->default(true), ->default(true),
Toggle::make('enable_auto_update') Toggle::make('enable_auto_update')
->label(__('Enable Auto-Updates')) ->label(__('Enable Auto-Updates'))
->visible(fn (Get $get): bool => filled($get('domain')))
->helperText(__('Automatically update WordPress, plugins, and themes')) ->helperText(__('Automatically update WordPress, plugins, and themes'))
->default(false), ->default(false),
]) ])
@@ -883,22 +937,75 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
} }
} }
public function createStaging(string $siteId, string $subdomain): void public function createStaging(string $siteId, string $subdomain, string $targetDomain = '', string $targetType = 'subdomain'): void
{ {
try { try {
$sourceSite = collect($this->sites)->firstWhere('id', $siteId);
$sourceDomain = (string) ($sourceSite['domain'] ?? '');
$normalizedSourceDomain = strtolower(trim($sourceDomain));
$agentPayload = [
'username' => $this->getUsername(),
'site_id' => $siteId,
];
if ($targetType === 'domain') {
$targetDomain = strtolower(trim($targetDomain));
if ($targetDomain === '') {
throw new Exception(__('Please choose a target domain.'));
}
if ($targetDomain === $normalizedSourceDomain) {
throw new Exception(__('The staging domain must be different from the source domain.'));
}
if (! $this->isOwnedDomain($targetDomain)) {
throw new Exception(__('The selected domain is not in your domain list.'));
}
$agentPayload['target_domain'] = $targetDomain;
} else {
$subdomain = strtolower(trim($subdomain));
if ($subdomain === '') {
throw new Exception(__('Please enter a subdomain.'));
}
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $subdomain)) {
throw new Exception(__('Subdomain can contain only letters, numbers, and hyphens.'));
}
$agentPayload['subdomain'] = $subdomain;
}
Notification::make() Notification::make()
->title(__('Creating Staging Environment...')) ->title(__('Creating Staging Environment...'))
->body(__('This may take several minutes.')) ->body(__('This may take several minutes.'))
->info() ->info()
->send(); ->send();
$result = $this->getAgent()->send('wp.create_staging', [ $result = $this->getAgent()->send('wp.create_staging', $agentPayload);
'username' => $this->getUsername(),
'site_id' => $siteId,
'subdomain' => 'staging-'.$subdomain,
]);
if ($result['success'] ?? false) { if ($result['success'] ?? false) {
$stagingDomain = (string) ($result['staging_domain'] ?? '');
if ($stagingDomain !== '') {
Domain::firstOrCreate(
[
'user_id' => Auth::id(),
'domain' => $stagingDomain,
],
[
'document_root' => '/home/'.$this->getUsername().'/domains/'.$stagingDomain.'/public_html',
'is_active' => true,
'ssl_enabled' => false,
'directory_index' => 'index.php index.html',
'page_cache_enabled' => false,
]
);
}
if (
$sourceDomain !== ''
&& $stagingDomain !== ''
&& str_ends_with(strtolower($stagingDomain), '.'.strtolower($sourceDomain))
) {
$this->ensureStagingDnsRecords($sourceDomain, $stagingDomain);
}
Notification::make() Notification::make()
->title(__('Staging Environment Created')) ->title(__('Staging Environment Created'))
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? ''])) ->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
@@ -1455,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
return file_exists(storage_path('app/public/screenshots/'.$filename)); return file_exists(storage_path('app/public/screenshots/'.$filename));
} }
protected function ensureStagingDnsRecords(string $sourceDomainName, string $stagingDomainName): void
{
$sourceDomain = Domain::query()
->where('user_id', Auth::id())
->where('domain', $sourceDomainName)
->first();
if (! $sourceDomain) {
return;
}
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
if ($label === null || $label === '') {
return;
}
$settings = DnsSetting::getAll();
$defaultTtl = (int) ($settings['default_ttl'] ?? 3600);
$defaultIpv4 = $sourceDomain->ip_address
?: ($settings['default_ip'] ?? trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')) ?: '127.0.0.1');
DnsRecord::query()->updateOrCreate(
[
'domain_id' => $sourceDomain->id,
'name' => $label,
'type' => 'A',
],
[
'content' => $defaultIpv4,
'ttl' => $defaultTtl,
'priority' => null,
]
);
$defaultIpv6 = $sourceDomain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
if (! empty($defaultIpv6)) {
DnsRecord::query()->updateOrCreate(
[
'domain_id' => $sourceDomain->id,
'name' => $label,
'type' => 'AAAA',
],
[
'content' => $defaultIpv6,
'ttl' => $defaultTtl,
'priority' => null,
]
);
}
try {
$this->syncDnsZone($sourceDomain, $settings);
} catch (Exception) {
// Keep staging creation successful even if DNS sync is temporarily unavailable.
}
}
protected function removeStagingDnsRecords(array $stagingSite): void
{
$stagingDomainName = strtolower(trim((string) ($stagingSite['domain'] ?? '')));
if ($stagingDomainName === '') {
return;
}
$sourceDomainName = '';
$sourceSiteId = $stagingSite['source_site_id'] ?? null;
if (is_string($sourceSiteId) && $sourceSiteId !== '') {
$sourceSite = collect($this->sites)->firstWhere('id', $sourceSiteId);
$sourceDomainName = strtolower(trim((string) ($sourceSite['domain'] ?? '')));
}
if ($sourceDomainName === '') {
$parts = explode('.', $stagingDomainName, 2);
if (count($parts) === 2) {
$sourceDomainName = $parts[1];
}
}
if ($sourceDomainName === '') {
return;
}
$sourceDomain = Domain::query()
->where('user_id', Auth::id())
->where('domain', $sourceDomainName)
->first();
if (! $sourceDomain) {
return;
}
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
if ($label === null || $label === '') {
return;
}
DnsRecord::query()
->where('domain_id', $sourceDomain->id)
->where('name', $label)
->whereIn('type', ['A', 'AAAA'])
->delete();
try {
$this->syncDnsZone($sourceDomain);
} catch (Exception) {
// Keep deletion successful even if DNS sync is temporarily unavailable.
}
}
protected function syncDnsZone(Domain $domain, ?array $settings = null): void
{
$settings ??= DnsSetting::getAll();
$records = DnsRecord::query()->where('domain_id', $domain->id)->get()->toArray();
$hostname = gethostname() ?: 'localhost';
$serverIp = trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? ''));
$this->getAgent()->dnsSyncZone($domain->domain, $records, [
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
'default_ip' => $settings['default_ip'] ?? ($serverIp !== '' ? $serverIp : '127.0.0.1'),
'default_ipv6' => $settings['default_ipv6'] ?? null,
'default_ttl' => $settings['default_ttl'] ?? 3600,
]);
}
protected function extractSubdomainLabel(string $fullDomain, string $baseDomain): ?string
{
$fullDomain = strtolower(trim($fullDomain, " \t\n\r\0\x0B."));
$baseDomain = strtolower(trim($baseDomain, " \t\n\r\0\x0B."));
if ($fullDomain === '' || $baseDomain === '') {
return null;
}
if ($fullDomain === $baseDomain) {
return null;
}
$suffix = '.'.$baseDomain;
if (! str_ends_with($fullDomain, $suffix)) {
return null;
}
$label = substr($fullDomain, 0, -strlen($suffix));
return trim((string) $label, '.');
}
protected function getOwnedDomainOptions(array $exclude = []): array
{
$excludeSet = [];
foreach ($exclude as $value) {
$normalized = strtolower(trim((string) $value));
if ($normalized !== '') {
$excludeSet[$normalized] = true;
}
}
$options = [];
foreach ($this->domains as $domain) {
$name = strtolower(trim((string) ($domain['domain'] ?? '')));
if ($name === '' || isset($excludeSet[$name])) {
continue;
}
$options[$name] = $name;
}
return $options;
}
protected function isOwnedDomain(string $domain): bool
{
$domain = strtolower(trim($domain));
if ($domain === '') {
return false;
}
foreach ($this->domains as $ownedDomain) {
$candidate = strtolower(trim((string) ($ownedDomain['domain'] ?? '')));
if ($candidate === $domain) {
return true;
}
}
return false;
}
} }

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Widgets;
use App\Models\AuditLog;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class ActivityLogTable extends Component implements HasTable, HasSchemas, HasActions
{
use InteractsWithTable;
use InteractsWithSchemas;
use InteractsWithActions;
public function table(Table $table): Table
{
return $table
->query(
AuditLog::query()
->where('user_id', Auth::id())
->latest()
)
->columns([
TextColumn::make('created_at')
->label(__('Time'))
->dateTime('M d, H:i')
->color('gray'),
TextColumn::make('category')
->label(__('Category'))
->badge()
->color(fn (string $state): string => match ($state) {
'domain' => 'info',
'email' => 'primary',
'database' => 'warning',
'auth' => 'gray',
'firewall' => 'danger',
'service' => 'success',
default => 'gray',
}),
TextColumn::make('action')
->label(__('Action'))
->badge()
->color(fn (string $state): string => match ($state) {
'create', 'created' => 'success',
'update', 'updated' => 'warning',
'delete', 'deleted' => 'danger',
'login' => 'info',
default => 'gray',
}),
TextColumn::make('description')
->label(__('Description'))
->limit(60)
->wrap(),
TextColumn::make('ip_address')
->label(__('IP'))
->color('gray'),
])
->defaultPaginationPageOption(25)
->striped()
->emptyStateHeading(__('No activity recorded yet'))
->emptyStateDescription(__('Recent actions performed in your account will appear here.'))
->emptyStateIcon('heroicon-o-clipboard-document-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace App\Filament\Jabali\Widgets;
use App\Models\ServerImport;
use App\Models\ServerImportAccount;
use Filament\Actions\Concerns\InteractsWithActions;
use Filament\Actions\Contracts\HasActions;
use Filament\Schemas\Concerns\InteractsWithSchemas;
use Filament\Schemas\Contracts\HasSchemas;
use Filament\Support\Contracts\TranslatableContentDriver;
use Filament\Support\Enums\FontWeight;
use Filament\Support\Enums\IconSize;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Concerns\InteractsWithTable;
use Filament\Tables\Contracts\HasTable;
use Filament\Tables\Table;
use Livewire\Attributes\On;
use Livewire\Component;
class DirectAdminMigrationStatusTable extends Component implements HasActions, HasSchemas, HasTable
{
use InteractsWithActions;
use InteractsWithSchemas;
use InteractsWithTable;
public ?int $importId = null;
public function mount(?int $importId = null): void
{
$this->importId = $importId ?: session('directadmin_self_migration.import_id');
}
#[On('directadmin-self-status-updated')]
public function refreshStatus(): void
{
$this->resetTable();
}
public function makeFilamentTranslatableContentDriver(): ?TranslatableContentDriver
{
return null;
}
protected function getImport(): ?ServerImport
{
if (! $this->importId) {
return null;
}
return ServerImport::find($this->importId);
}
/**
* @return \Illuminate\Support\Collection<int, ServerImportAccount>
*/
protected function getRecords()
{
if (! $this->importId) {
return collect();
}
return ServerImportAccount::query()
->where('server_import_id', $this->importId)
->orderBy('source_username')
->get();
}
protected function shouldPoll(): bool
{
$import = $this->getImport();
if (! $import) {
return false;
}
if (in_array($import->status, ['discovering', 'importing'], true)) {
return true;
}
foreach ($this->getRecords() as $record) {
if (! in_array($record->status, ['completed', 'failed', 'skipped'], true)) {
return true;
}
}
return false;
}
protected function getStatusText(string $status): string
{
return match ($status) {
'pending' => __('Waiting...'),
'importing' => __('Importing...'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'skipped' => __('Skipped'),
default => __('Unknown'),
};
}
public function table(Table $table): Table
{
return $table
->records(fn () => $this->getRecords())
->columns([
IconColumn::make('status_icon')
->label('')
->icon(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'heroicon-o-clock',
'importing' => 'heroicon-o-arrow-path',
'completed' => 'heroicon-o-check-circle',
'failed' => 'heroicon-o-x-circle',
'skipped' => 'heroicon-o-minus-circle',
default => 'heroicon-o-question-mark-circle',
})
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
})
->size(IconSize::Small)
->extraAttributes(fn (ServerImportAccount $record): array => $record->status === 'importing'
? ['class' => 'animate-spin']
: []),
TextColumn::make('source_username')
->label(__('Account'))
->weight(FontWeight::Bold)
->searchable(),
TextColumn::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn (string $state): string => $this->getStatusText($state))
->color(fn (ServerImportAccount $record): string => match ($record->status) {
'pending' => 'gray',
'importing' => 'warning',
'completed' => 'success',
'failed' => 'danger',
'skipped' => 'gray',
default => 'gray',
}),
TextColumn::make('current_task')
->label(__('Current Task'))
->wrap()
->limit(80)
->default(__('Waiting...')),
TextColumn::make('progress')
->label(__('Progress'))
->suffix('%')
->toggleable(),
])
->striped()
->paginated(false)
->poll($this->shouldPoll() ? '3s' : null)
->emptyStateHeading(__('No migration activity'))
->emptyStateDescription(__('Discover an account and start migration.'))
->emptyStateIcon('heroicon-o-queue-list');
}
public function render()
{
return $this->getTable()->render();
}
}

View File

@@ -11,9 +11,17 @@ use Illuminate\Http\Request;
class GitWebhookController extends Controller class GitWebhookController extends Controller
{ {
public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse public function __invoke(Request $request, GitDeployment $deployment, ?string $token = null): JsonResponse
{ {
if (! hash_equals($deployment->secret_token, $token)) { $payload = $request->getContent();
$providedSignature = (string) ($request->header('X-Jabali-Signature') ?? $request->header('X-Hub-Signature-256') ?? '');
$providedSignature = preg_replace('/^sha256=/i', '', trim($providedSignature)) ?: '';
$expectedSignature = hash_hmac('sha256', $payload, $deployment->secret_token);
$hasValidSignature = $providedSignature !== '' && hash_equals($expectedSignature, $providedSignature);
$hasValidLegacyToken = $token !== null && hash_equals($deployment->secret_token, $token);
if (! $hasValidSignature && ! $hasValidLegacyToken) {
return response()->json(['message' => 'Invalid token'], 403); return response()->json(['message' => 'Invalid token'], 403);
} }

View File

@@ -185,3 +185,53 @@ class BackupSchedule extends Model
return $timezone; 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()); \Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
} }
@@ -100,6 +100,7 @@ class User extends Authenticatable implements FilamentUser
$masterUser = $user->username.'_admin'; $masterUser = $user->username.'_admin';
try { try {
if (class_exists(\mysqli::class)) {
// Use credentials from environment variables // Use credentials from environment variables
$mysqli = new \mysqli( $mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'), config('database.connections.mysql.host', 'localhost'),
@@ -120,12 +121,16 @@ class User extends Authenticatable implements FilamentUser
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'"); $mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close(); $mysqli->close();
} }
}
// Delete stored credentials } catch (\Throwable $e) {
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Exception $e) {
\Log::error('Failed to delete master MySQL user: '.$e->getMessage()); \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());
}
}); });
} }
@@ -159,30 +164,27 @@ class User extends Authenticatable implements FilamentUser
*/ */
public function getDiskUsageBytes(): int public function getDiskUsageBytes(): int
{ {
// Try to get usage from quota system first (more accurate) // Disk usage must be obtained via the agent (root) to avoid permission-based undercounting.
try { try {
$agent = new \App\Services\Agent\AgentClient; $agent = new \App\Services\Agent\AgentClient(
$result = $agent->quotaGet($this->username, '/'); (string) config('jabali.agent.socket', '/var/run/jabali/agent.sock'),
(int) config('jabali.agent.timeout', 120),
);
$mount = $this->home_directory ?: ("/home/{$this->username}");
$result = $agent->quotaGet($this->username, $mount);
if (($result['success'] ?? false) && isset($result['used_mb'])) { if (($result['success'] ?? false) && isset($result['used_mb'])) {
return (int) ($result['used_mb'] * 1024 * 1024); return (int) ($result['used_mb'] * 1024 * 1024);
} }
} catch (\Exception $e) { } catch (\Throwable $e) {
// Fall back to du command \Log::warning('Disk usage read failed via agent: '.$e->getMessage(), [
'username' => $this->username,
]);
} }
// Fallback: try du command (may not work if www-data can't read home dir)
$homeDir = $this->home_directory;
if (! is_dir($homeDir)) {
return 0; return 0;
} }
$output = shell_exec('du -sb '.escapeshellarg($homeDir).' 2>/dev/null | cut -f1');
return (int) trim($output ?: '0');
}
/** /**
* Get formatted disk usage string. * Get formatted disk usage string.
*/ */

View File

@@ -5,17 +5,18 @@ namespace App\Providers;
use App\Models\Domain; use App\Models\Domain;
use App\Observers\DomainObserver; use App\Observers\DomainObserver;
use Filament\Support\Facades\FilamentAsset; use Filament\Support\Facades\FilamentAsset;
use Illuminate\Support\ServiceProvider; use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/** /**
* Register any application services. * Register any application services.
*/ */
public function register(): void public function register(): void {}
{
}
/** /**
* Bootstrap any application services. * Bootstrap any application services.
@@ -24,6 +25,31 @@ class AppServiceProvider extends ServiceProvider
{ {
Domain::observe(DomainObserver::class); Domain::observe(DomainObserver::class);
RateLimiter::for('api', function (Request $request): array {
$identifier = $request->user()?->getAuthIdentifier() ?? $request->ip();
return [
Limit::perMinute(120)->by('api:'.$identifier),
];
});
RateLimiter::for('internal-api', function (Request $request): array {
$remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip());
return [
Limit::perMinute(60)->by('internal:'.$remoteAddr),
];
});
RateLimiter::for('git-webhooks', function (Request $request): array {
$deploymentId = $request->route('deployment');
$deploymentKey = is_object($deploymentId) ? (string) $deploymentId->getKey() : (string) $deploymentId;
return [
Limit::perMinute(120)->by('webhook:'.$deploymentKey.':'.$request->ip()),
];
});
$versionFile = base_path('VERSION'); $versionFile = base_path('VERSION');
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null; $appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
FilamentAsset::appVersion($appVersion ?: null); FilamentAsset::appVersion($appVersion ?: null);

View File

@@ -504,13 +504,19 @@ class AgentClient
return $this->send('wp.import', $params); return $this->send('wp.import', $params);
} }
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array public function wpCreateStaging(string $username, string $siteId, string $subdomain = 'staging', ?string $targetDomain = null): array
{ {
return $this->send('wp.create_staging', [ $params = [
'username' => $username, 'username' => $username,
'site_id' => $siteId, 'site_id' => $siteId,
'subdomain' => $subdomain, 'subdomain' => $subdomain,
]); ];
if ($targetDomain !== null && $targetDomain !== '') {
$params['target_domain'] = $targetDomain;
}
return $this->send('wp.create_staging', $params);
} }
public function wpPushStaging(string $username, string $stagingSiteId): array public function wpPushStaging(string $username, string $stagingSiteId): array

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
@@ -12,7 +13,24 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up', health: '/up',
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*'); $trustedProxies = env('TRUSTED_PROXIES');
$resolvedProxies = match (true) {
is_string($trustedProxies) && trim($trustedProxies) === '*' => '*',
is_string($trustedProxies) && trim($trustedProxies) !== '' => array_values(array_filter(array_map(
static fn (string $proxy): string => trim($proxy),
explode(',', $trustedProxies)
))),
default => ['127.0.0.1', '::1'],
};
$middleware->trustProxies(
at: $resolvedProxies,
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO
);
$middleware->throttleApi('api');
$middleware->append(\App\Http\Middleware\SecurityHeaders::class); $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@@ -17,7 +17,7 @@
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
"filament/blueprint": "^2.0", "filament/blueprint": "^2.1",
"laravel/boost": "*", "laravel/boost": "*",
"laravel/pail": "^1.2.2", "laravel/pail": "^1.2.2",
"laravel/pint": "^1.24", "laravel/pint": "^1.24",

127
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "194d87cc129a30c6e832109fb820097a", "content-hash": "7083b0b087c4b503b50d3aa23cfbbfac",
"packages": [ "packages": [
{ {
"name": "anourvalar/eloquent-serialize", "name": "anourvalar/eloquent-serialize",
@@ -5286,16 +5286,16 @@
}, },
{ {
"name": "psy/psysh", "name": "psy/psysh",
"version": "v0.12.18", "version": "v0.12.20",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/bobthecow/psysh.git", "url": "https://github.com/bobthecow/psysh.git",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196" "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196", "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196", "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5359,9 +5359,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/bobthecow/psysh/issues", "issues": "https://github.com/bobthecow/psysh/issues",
"source": "https://github.com/bobthecow/psysh/tree/v0.12.18" "source": "https://github.com/bobthecow/psysh/tree/v0.12.20"
}, },
"time": "2025-12-17T14:35:46+00:00" "time": "2026-02-11T15:05:28+00:00"
}, },
{ {
"name": "ralouphie/getallheaders", "name": "ralouphie/getallheaders",
@@ -5981,16 +5981,16 @@
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v7.4.3", "version": "v7.4.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6" "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6", "reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6055,7 +6055,7 @@
"terminal" "terminal"
], ],
"support": { "support": {
"source": "https://github.com/symfony/console/tree/v7.4.3" "source": "https://github.com/symfony/console/tree/v7.4.4"
}, },
"funding": [ "funding": [
{ {
@@ -6075,7 +6075,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-23T14:50:43+00:00" "time": "2026-01-13T11:36:38+00:00"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
@@ -7803,16 +7803,16 @@
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v7.4.3", "version": "v7.4.5",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/process.git", "url": "https://github.com/symfony/process.git",
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f" "reference": "608476f4604102976d687c483ac63a79ba18cc97"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f", "reference": "608476f4604102976d687c483ac63a79ba18cc97",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7844,7 +7844,7 @@
"description": "Executes commands in sub-processes", "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/process/tree/v7.4.3" "source": "https://github.com/symfony/process/tree/v7.4.5"
}, },
"funding": [ "funding": [
{ {
@@ -7864,7 +7864,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-19T10:00:43+00:00" "time": "2026-01-26T15:07:59+00:00"
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
@@ -8040,16 +8040,16 @@
}, },
{ {
"name": "symfony/string", "name": "symfony/string",
"version": "v8.0.1", "version": "v8.0.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/string.git", "url": "https://github.com/symfony/string.git",
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc" "reference": "758b372d6882506821ed666032e43020c4f57194"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc", "url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc", "reference": "758b372d6882506821ed666032e43020c4f57194",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8106,7 +8106,7 @@
"utf8" "utf8"
], ],
"support": { "support": {
"source": "https://github.com/symfony/string/tree/v8.0.1" "source": "https://github.com/symfony/string/tree/v8.0.4"
}, },
"funding": [ "funding": [
{ {
@@ -8126,7 +8126,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-01T09:13:36+00:00" "time": "2026-01-12T12:37:40+00:00"
}, },
{ {
"name": "symfony/translation", "name": "symfony/translation",
@@ -8383,16 +8383,16 @@
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
"version": "v7.4.3", "version": "v7.4.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/var-dumper.git", "url": "https://github.com/symfony/var-dumper.git",
"reference": "7e99bebcb3f90d8721890f2963463280848cba92" "reference": "0e4769b46a0c3c62390d124635ce59f66874b282"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92", "url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282",
"reference": "7e99bebcb3f90d8721890f2963463280848cba92", "reference": "0e4769b46a0c3c62390d124635ce59f66874b282",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -8446,7 +8446,7 @@
"dump" "dump"
], ],
"support": { "support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.4.3" "source": "https://github.com/symfony/var-dumper/tree/v7.4.4"
}, },
"funding": [ "funding": [
{ {
@@ -8466,7 +8466,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-18T07:04:31+00:00" "time": "2026-01-01T22:13:48+00:00"
}, },
{ {
"name": "tijsverkoyen/css-to-inline-styles", "name": "tijsverkoyen/css-to-inline-styles",
@@ -8817,14 +8817,14 @@
}, },
{ {
"name": "filament/blueprint", "name": "filament/blueprint",
"version": "v2.0.1", "version": "v2.1.0",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://packages.filamentphp.com/composer/10/127/download" "url": "https://packages.filamentphp.com/composer/10/473/download"
}, },
"require": { "require": {
"filament/support": "^5.0", "filament/support": "^5.0",
"laravel/boost": "^1.8" "laravel/boost": "^1.8|^2.0"
}, },
"type": "library", "type": "library",
"license": [ "license": [
@@ -9820,28 +9820,28 @@
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
"version": "5.1.0", "version": "5.1.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=8.2" "php": ">=8.2"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^11.0" "phpunit/phpunit": "^11.3"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-main": "5.0-dev" "dev-main": "5.1-dev"
} }
}, },
"autoload": { "autoload": {
@@ -9869,15 +9869,27 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1"
}, },
"funding": [ "funding": [
{ {
"url": "https://github.com/sebastianbergmann", "url": "https://github.com/sebastianbergmann",
"type": "github" "type": "github"
},
{
"url": "https://liberapay.com/sebastianbergmann",
"type": "liberapay"
},
{
"url": "https://thanks.dev/u/gh/sebastianbergmann",
"type": "thanks_dev"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
"type": "tidelift"
} }
], ],
"time": "2024-08-27T05:02:59+00:00" "time": "2026-02-02T13:52:54+00:00"
}, },
{ {
"name": "phpunit/php-invoker", "name": "phpunit/php-invoker",
@@ -10065,16 +10077,16 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "11.5.48", "version": "11.5.53",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89" "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fe3665c15e37140f55aaf658c81a2eb9030b6d89", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89", "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10089,18 +10101,19 @@
"phar-io/version": "^3.2.1", "phar-io/version": "^3.2.1",
"php": ">=8.2", "php": ">=8.2",
"phpunit/php-code-coverage": "^11.0.12", "phpunit/php-code-coverage": "^11.0.12",
"phpunit/php-file-iterator": "^5.1.0", "phpunit/php-file-iterator": "^5.1.1",
"phpunit/php-invoker": "^5.0.1", "phpunit/php-invoker": "^5.0.1",
"phpunit/php-text-template": "^4.0.1", "phpunit/php-text-template": "^4.0.1",
"phpunit/php-timer": "^7.0.1", "phpunit/php-timer": "^7.0.1",
"sebastian/cli-parser": "^3.0.2", "sebastian/cli-parser": "^3.0.2",
"sebastian/code-unit": "^3.0.3", "sebastian/code-unit": "^3.0.3",
"sebastian/comparator": "^6.3.2", "sebastian/comparator": "^6.3.3",
"sebastian/diff": "^6.0.2", "sebastian/diff": "^6.0.2",
"sebastian/environment": "^7.2.1", "sebastian/environment": "^7.2.1",
"sebastian/exporter": "^6.3.2", "sebastian/exporter": "^6.3.2",
"sebastian/global-state": "^7.0.2", "sebastian/global-state": "^7.0.2",
"sebastian/object-enumerator": "^6.0.1", "sebastian/object-enumerator": "^6.0.1",
"sebastian/recursion-context": "^6.0.3",
"sebastian/type": "^5.1.3", "sebastian/type": "^5.1.3",
"sebastian/version": "^5.0.2", "sebastian/version": "^5.0.2",
"staabm/side-effects-detector": "^1.0.5" "staabm/side-effects-detector": "^1.0.5"
@@ -10146,7 +10159,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.48" "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53"
}, },
"funding": [ "funding": [
{ {
@@ -10170,7 +10183,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-16T16:26:27+00:00" "time": "2026-02-10T12:28:25+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
@@ -10344,16 +10357,16 @@
}, },
{ {
"name": "sebastian/comparator", "name": "sebastian/comparator",
"version": "6.3.2", "version": "6.3.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git", "url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10412,7 +10425,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues", "issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy", "security": "https://github.com/sebastianbergmann/comparator/security/policy",
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
}, },
"funding": [ "funding": [
{ {
@@ -10432,7 +10445,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-08-10T08:07:46+00:00" "time": "2026-01-24T09:26:40+00:00"
}, },
{ {
"name": "sebastian/complexity", "name": "sebastian/complexity",
@@ -11346,5 +11359,5 @@
"php": "^8.2" "php": "^8.2"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

26
config.toml.example Normal file
View File

@@ -0,0 +1,26 @@
# Jabali Panel repository config
#
# Used by `scripts/deploy.sh` (CLI flags still override these settings).
# Keep secrets out of this file. Prefer SSH keys and server-side git remotes.
[deploy]
# Test server (where GitHub deploy key is configured)
host = "192.168.100.50"
user = "root"
path = "/var/www/jabali"
www_user = "www-data"
# Optional: keep npm cache outside the repo (saves time on repeated builds)
# npm_cache_dir = "/var/www/.npm"
# Optional: override the branch that gets pushed from the deploy server
push_branch = "main"
# Optional: push to explicit URLs (instead of relying on named remotes)
# These pushes run FROM the test server.
gitea_url = "ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git"
github_url = "git@github.com:shukiv/jabali-panel.git"
# If you prefer named remotes on the deploy server instead of URLs:
# gitea_remote = "gitea"
# github_remote = "origin"

View File

@@ -123,4 +123,15 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'), 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
/*
|--------------------------------------------------------------------------
| Internal API Token
|--------------------------------------------------------------------------
|
| Optional shared token for internal endpoints that may be called from
| non-localhost environments (for example, when using a reverse proxy).
|
*/
'internal_api_token' => env('JABALI_INTERNAL_API_TOKEN'),
]; ];

View File

@@ -37,6 +37,13 @@ return [
'root' => '/tmp', 'root' => '/tmp',
'throw' => false, 'throw' => false,
], ],
// Server-wide backups folder (created by install.sh)
'backups' => [
'driver' => 'local',
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
'throw' => false,
],
], ],
'links' => [ 'links' => [

View File

@@ -15,6 +15,12 @@ This page provides git deployment features for the jabali panel.
- Use git deployment to complete common operational tasks. - Use git deployment to complete common operational tasks.
- Review this page after configuration changes to confirm results. - Review this page after configuration changes to confirm results.
## Webhook Security
- Use the provided `Webhook URL` with the `Webhook Secret`.
- Jabali validates `X-Jabali-Signature` (or `X-Hub-Signature-256`) as HMAC-SHA256 over the raw request body.
- Legacy tokenized webhook URLs remain supported for older integrations.
## Typical examples ## Typical examples
- Example 1: Use git deployment to complete common operational tasks. - Example 1: Use git deployment to complete common operational tasks.

View File

@@ -0,0 +1,277 @@
# DirectAdmin Migration Blueprint
This blueprint describes how Jabali Panel should migrate accounts from a remote
DirectAdmin server into Jabali.
It is written to match Jabali's current architecture:
- Laravel 12 + Filament v5 + Livewire v4 UI.
- Privileged agent (`bin/jabali-agent`) for root-level operations.
- Long-running work via jobs/queue with resumable logs.
## Goals
- Support connecting to a remote DirectAdmin server (host, port, credentials).
- Support multi-account migrations (admin-initiated).
- Support user self-migration (user-initiated, scoped to their Jabali account).
- Migrate websites, databases, email, and SSL.
- Provide clear progress, per-account logs, and safe retries.
## Non-Goals (Initial Scope)
- Reseller plans and quota mapping (can be added later).
- DNS zone migrations from DirectAdmin (optional later).
- Password migration for website logins and mailboxes (not possible in general).
## UX Overview
Jabali already has:
- Admin migration entry: `jabali-admin/migration` (tabs page).
- User migration entry: `jabali-panel/cpanel-migration` (cPanel only today).
DirectAdmin migration should be added to both panels:
- Admin: new migration tab alongside cPanel and WHM.
- User: new self-migration page similar to user cPanel migration.
The UI should use Filament native components (Wizard, Sections, Tables), and
should not embed custom HTML/CSS.
## Admin Flow (Multi-Account)
### Step 1: Connect
Inputs:
- Hostname or IP
- Port (default DirectAdmin: 2222)
- Auth:
- Username + password (initial)
- Optional future: API token
- SSL verify toggle (default on, allow off for lab servers)
Actions:
- Test connection
- Discover users/accounts
Output:
- Server metadata (DirectAdmin version if available)
- Discovered accounts summary
### Step 2: Select Accounts
Show a table of discovered accounts:
- Source username
- Main domain
- Email contact
- Disk usage (if provided)
Selection:
- Multi-select accounts for import.
Per-account mapping:
- Target Jabali username (editable, default = source username)
- Target user email (editable)
- Conflict indicators (existing Jabali user, existing domains)
### Step 3: Choose What To Import
Toggles:
- Files
- Databases
- Email
- SSL
Optional safety toggles:
- Skip existing domains
- Skip existing databases
- Re-issue SSL via Let's Encrypt when custom SSL is missing or invalid
### Step 4: Run Migration
Execution runs as a background job batch:
- Per-account status: pending, running, completed, failed, skipped.
- Per-account logs (timestamps + messages).
- Global log for the import job.
Controls:
- Cancel import (best-effort stop at safe boundaries).
- Retry failed accounts.
## User Flow (Self-Migration)
User self-migration is a guided flow to import a single DirectAdmin account into
the currently authenticated Jabali user.
### Step 1: Connect
Inputs:
- DirectAdmin hostname/IP and port
- DirectAdmin username + password
Actions:
- Test connection
- Verify the DirectAdmin user is accessible
### Step 2: Choose What To Import
Toggles:
- Files
- Databases
- Email
- SSL
### Step 3: Run Migration
Show:
- Live progress
- Logs
- Final summary
Scope and enforcement:
- Target Jabali user is fixed to the authenticated user.
- Import must refuse to touch domains that do not belong to the user.
## Data Model (Proposed)
Jabali already has:
- `server_imports` and `server_import_accounts`.
To support DirectAdmin remote migration properly, add:
- `server_imports.created_by_user_id` (nullable, for admin-created vs user-created).
- `server_imports.target_user_id` (nullable, for admin selecting a target user, optional).
- `server_import_accounts.backup_path` (nullable, per-account backup archive path when remote).
- `server_import_accounts.ssl_items` (json, optional, discovered SSL material per domain).
- `server_import_accounts.mail_items` (json, optional, discovered mailboxes and domains).
Also update `server_imports.import_options` to include:
- `files`, `databases`, `emails`, `ssl`.
## Import Pipeline (High-Level)
### Phase A: Discovery
Purpose:
- Validate credentials and enumerate accounts to import.
Implementation notes:
- Jabali agent already supports discovery for DirectAdmin remote via:
- `CMD_API_SHOW_ALL_USERS`
- `CMD_API_SHOW_USER_CONFIG`
### Phase B: Backup Creation and Download (Remote Method)
Problem:
- The current import processor only imports from local backup archives.
Solution:
- For `import_method=remote_server`, create and download a DirectAdmin backup
per selected account to `storage/app/private/imports/...`.
Implementation choices:
- Run this phase in a queued job to avoid request timeouts.
- Download must stream to disk, not to memory.
- Store paths per account (`server_import_accounts.backup_path`).
### Phase C: Analyze Backup (Optional But Recommended)
Purpose:
- Extract account metadata (domains, DB dumps, email list, SSL presence).
- Show a preview before the destructive restore phase.
Implementation notes:
- Reuse the existing agent discovery for backup files (`import.discover`).
- Extend discovery to detect:
- Mailbox domains and mailbox names
- SSL certificate files per domain (if present)
### Phase D: Restore Into Jabali
For each account:
1. Create or map Jabali user.
2. Create domains.
3. Restore website files into the correct document roots.
4. Restore databases and import dumps.
5. Restore email domains and mailboxes, then copy Maildir data.
6. Restore SSL certificates (or issue Let's Encrypt if configured).
All steps must write logs to both:
- `server_imports.import_log`
- `server_import_accounts.import_log`
## Email Migration (Requirements)
Minimum requirements:
- Create mail domains in Jabali for the migrated domains.
- Create mailboxes for the discovered mailbox usernames.
- Import Maildir content (messages, folders) into the new mailboxes.
Recommended approach:
- Use Jabali agent functions:
- `email.enable_domain`
- `email.mailbox_create`
- `email.sync_maps` and `email.reload_services` when needed
Notes:
- DirectAdmin backups can include hashed mailbox passwords, which are not
directly reusable for Dovecot. Use new random passwords and provide a
"reset passwords" output list to the admin or user.
## SSL Migration (Requirements)
Minimum requirements:
- If custom SSL material is present in the DirectAdmin backup, install it for
each domain in Jabali.
Recommended approach:
- Extract PEM certificate, private key, and optional chain from the backup.
- Use Jabali agent function `ssl.install` per domain.
Fallback:
- If SSL material is missing or invalid, allow issuing Let's Encrypt via
`ssl.issue` after DNS and vhost are ready.
## Multi-Account Support
Admin migration must allow selecting multiple DirectAdmin users and migrating
them in one batch.
Execution model:
- One `server_imports` record per batch.
- One `server_import_accounts` record per DirectAdmin user.
- Independent status and retry per account.
## Security and Compliance
- Store remote passwords and tokens encrypted at rest (already supported for
`server_imports.remote_password` and `server_imports.remote_api_token`).
- Never write raw credentials into logs.
- Provide a "forget credentials" action after the migration completes.
- Rate-limit connection tests and discovery to reduce abuse.
## Observability
- Show per-account progress and current step in the UI.
- Write a compact global log and a detailed per-account log.
- On failures, capture enough context to troubleshoot:
- Which step failed
- The relevant domain or database name
- A short error string without secrets
## Implementation Phases (Suggested)
Phase 1:
- Admin: add DirectAdmin migration tab (UI skeleton).
- User: add DirectAdmin self-migration page (UI skeleton).
- Wire UI to the existing `server_imports` discovery (remote and backup-file).
Phase 2:
- Implement remote backup creation and download.
- Store per-account backup paths.
- Make `import:process` support `remote_server` by using downloaded archives.
Phase 3:
- Implement email restore (mail domains, mailboxes, Maildir copy).
- Implement SSL restore (custom cert install, LE fallback).
Phase 4:
- Add tests (discovery, permissions, per-account import state).
- Add docs and screenshots for the new pages/tabs.

View File

@@ -0,0 +1,150 @@
# MCP and Filament Blueprint (Jabali Panel)
Last updated: 2026-02-12
This document is an internal developer blueprint for working on Jabali Panel with:
- MCP tooling (Model Context Protocol) for fast, version-correct introspection and docs.
- Filament (Admin + User panels) conventions and project-specific UI rules.
## Goals
- Keep changes consistent with the existing architecture and UI.
- Prefer version-specific documentation and project-aware inspection.
- Avoid UI regressions by following Filament-native patterns.
- Keep privileged operations isolated behind the agent.
## MCP Tooling Blueprint
Jabali is set up to be worked on with MCP tools. Use them to reduce guesswork and prevent version drift.
### 1) Laravel Boost (Most Important)
Laravel Boost MCP gives application-aware tools (routes, config, DB schema, logs, and version-specific docs).
Use it when:
- You need to confirm route names/paths and middleware.
- You need to confirm the active config (not just what you expect in `.env`).
- You need the DB schema or sample records to understand existing behavior.
- You need version-specific docs for Laravel/Livewire/Filament/Tailwind.
Preferred workflow:
- `application-info` to confirm versions and installed packages.
- `list-routes` to find the correct URL, route names, and panel prefixes.
- `get-config` for runtime config values.
- `database-schema` and `database-query` (read-only) to verify tables and relationships.
- `read-log-entries` / `last-error` to confirm the active failure.
- `search-docs` before implementing anything that depends on framework behavior.
Project rule of thumb:
- Before making a structural change in the panel, list relevant routes and key config values first.
### 2) Jabali Docs MCP Server
The repository includes `mcp-docs-server/` which exposes project docs as MCP resources/tools.
What it is useful for:
- Quick search across `README.md`, `AGENT.md`, and changelog content.
- Pulling a specific section by title.
This is not a runtime dependency of the panel. It is a developer tooling layer.
### 3) Frontend and Quality MCPs
Use these to audit and reduce UI/HTML/CSS regressions:
- `css-mcp`:
- Analyze CSS quality/complexity.
- Check browser compatibility for specific CSS features.
- Pull MDN docs for CSS properties/selectors when implementing UI.
- `stylelint`:
- Lint CSS where applicable (note: Filament pages should not use custom CSS files).
- `webdev-tools`:
- Prettier formatting for snippets.
- `php -l` lint for PHP syntax.
- HTML validation for standalone HTML.
Security rule:
- Do not send secrets (tokens, passwords, private keys) into any tool query.
## Filament Blueprint (How Jabali Panels Are Built)
Jabali has two Filament panels:
- Admin panel: server-wide operations.
- User panel ("Jabali" panel): tenant/user operations.
High-level structure:
- `app/Filament/Admin/*` for admin.
- `app/Filament/Jabali/*` for user.
### Pages vs Resources
Default decision:
- Use a Filament Resource when the UI is primarily CRUD around an Eloquent model.
- Use a Filament Page when the UI is a dashboard, a multi-step wizard, or merges multiple concerns into a single screen.
### Project UI Rules (Strict)
These rules exist to keep the UI consistent and maintainable:
- Use Filament native components for layout and UI.
- Avoid raw HTML layout in Filament pages.
- Avoid custom CSS for Filament pages.
- Use Filament tables for list data.
Practical mapping:
- Layout: `Filament\Schemas\Components\Section`, `Grid`, `Tabs`, `Group`.
- Actions: `Filament\Actions\Action`.
- List data: `HasTable` / `InteractsWithTable` or `EmbeddedTable`.
### Tabs + Tables Gotcha
There is a known class of issues when a table is nested incorrectly inside schema Tabs.
Rule of thumb:
- Prefer `EmbeddedTable::make()` in schema layouts.
- Avoid mounting tables inside `View::make()` within `Tabs::make()` unless you know the action mounting behavior is preserved.
### Translations and RTL
Jabali uses JSON-based translations.
Rules:
- Use the English string as the translation key: `__('Create Domain')`.
- Do not introduce dotted translation keys like `__('domain.create')`.
- Ensure UI reads correctly in RTL locales (Arabic/Hebrew).
### Privileged Operations (Agent Boundary)
The Laravel app is the control plane. Privileged system operations are executed by the root-level agent.
Key points:
- The agent is `bin/jabali-agent`.
- The panel should call privileged operations through the Agent client service (not by shelling out directly).
- Keep all path and input validation strict before an agent call.
## Filament Blueprint Planning (Feature Specs)
When writing an implementation plan for a Filament feature, use Filament Blueprint planning docs as a checklist.
Reference:
- `vendor/filament/blueprint/resources/markdown/planning/overview.md`
At minimum, a plan should specify:
- Data model changes (tables, columns, indexes, relationships).
- Panel placement (Admin vs User) and navigation.
- Page/Resource decisions.
- Authorization model (policies/guards).
- Background jobs (for long-running operations).
- Audit logging events.
- Tests (Feature tests for endpoints and Livewire/Filament behaviors).
## Development Checklist (Per Feature)
- Confirm the correct panel and route prefix.
- List routes and verify config assumptions (Boost tools).
- Follow Filament-native components (no custom HTML/CSS in Filament pages).
- Use tables for list data.
- Keep agent boundary intact for privileged operations.
- Add or update tests and run targeted test commands.

View File

@@ -1,6 +1,6 @@
# Jabali Documentation Index # Jabali Documentation Index
Last updated: 2026-02-06 Last updated: 2026-02-10
## Top-Level Docs ## Top-Level Docs
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary. - /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
@@ -11,8 +11,10 @@ Last updated: 2026-02-06
- /var/www/jabali/TODO.md - Active checklist items. - /var/www/jabali/TODO.md - Active checklist items.
## Docs Folder ## Docs Folder
- /var/www/jabali/docs/installation.md - Debian package install path and Filament notifications patch. - /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel. - /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
- /var/www/jabali/docs/architecture/mcp-and-filament-blueprint.md - Developer blueprint for MCP tooling and Filament panel conventions.
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes. - /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions. - /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated). - /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).

View File

@@ -1,6 +1,6 @@
# Documentation Summary (Jabali Panel) # Documentation Summary (Jabali Panel)
Last updated: 2026-02-06 Last updated: 2026-02-10
## Product Overview ## Product Overview
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity. Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
@@ -41,6 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack. - Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
- Installer: install.sh, builds assets as www-data and ensures permissions. - Installer: install.sh, builds assets as www-data and ensures permissions.
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules. - Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
- Deploy helper: scripts/deploy.sh rsyncs to `root@192.168.100.50`, commits there, bumps VERSION, updates install.sh fallback, pushes to Git remotes from that server, then runs composer/npm, migrations, and caches.
## Packaging ## Packaging
Debian packaging is supported via scripts: Debian packaging is supported via scripts:
@@ -52,5 +53,6 @@ mcp-docs-server exposes README, AGENT docs, and changelog through MCP tools for
## Miscellaneous Docs ## Miscellaneous Docs
- Screenshot regeneration script: tests/take-screenshots.cjs. - Screenshot regeneration script: tests/take-screenshots.cjs.
- DirectAdmin migration blueprint: docs/architecture/directadmin-migration-blueprint.md.
- Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders. - Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders.
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin. - WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.

View File

@@ -110,6 +110,71 @@ What is included:
If you update or rebuild assets, keep the guard in place and hardrefresh the If you update or rebuild assets, keep the guard in place and hardrefresh the
browser (Ctrl+Shift+R) after deployment. browser (Ctrl+Shift+R) after deployment.
## Deploy script
The repository ships with a deploy helper at `scripts/deploy.sh`. It rsyncs the
project to `root@192.168.100.50:/var/www/jabali`, commits on that server, bumps
`VERSION`, updates the `install.sh` fallback, and pushes to Git remotes from
that server. Then it runs composer/npm, migrations, and cache rebuilds.
Defaults (override via flags, env vars, or config.toml):
Config file:
- `config.toml` (ignored by git) is read automatically if present.
- Start from `config.toml.example`.
- Set `CONFIG_FILE` to use an alternate TOML file path.
- Supported keys are in `[deploy]` (for example: `host`, `user`, `path`, `www_user`, `push_branch`, `gitea_url`, `github_url`).
- Host: `192.168.100.50`
- User: `root`
- Path: `/var/www/jabali`
- Web user: `www-data`
Common usage:
```
# Basic deploy to the default host
scripts/deploy.sh
# Target a different host/path/user
scripts/deploy.sh --host 192.168.100.50 --user root --path /var/www/jabali --www-user www-data
# Dry-run rsync only
scripts/deploy.sh --dry-run
# Skip npm build and cache steps
scripts/deploy.sh --skip-npm --skip-cache
```
Push behavior controls:
```
# Deploy only (no push)
scripts/deploy.sh --skip-push
# Push only one remote
scripts/deploy.sh --no-push-github
scripts/deploy.sh --no-push-gitea
# Push to explicit URLs from the test server
scripts/deploy.sh --gitea-url ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git \
--github-url git@github.com:shukiv/jabali-panel.git
```
GitHub push location:
```
# Push to GitHub from the test server (required)
ssh root@192.168.100.50
cd /var/www/jabali
git push origin main
```
Notes:
- Pushes run from `root@192.168.100.50` (not from local machine).
- Before each push, the script bumps `VERSION`, updates `install.sh` fallback,
and commits on the deploy server.
- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
`bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
- `--delete` passes `--delete` to rsync (dangerous).
## Testing after changes ## Testing after changes
After every change, run a test to make sure there are no errors. After every change, run a test to make sure there are no errors.

View File

@@ -51,6 +51,7 @@ From /var/www/jabali:
- Do not push unless explicitly asked. - Do not push unless explicitly asked.
- Bump VERSION before every push. - Bump VERSION before every push.
- Keep install.sh version fallback in sync with VERSION. - Keep install.sh version fallback in sync with VERSION.
- Push to GitHub from `root@192.168.100.50`.
## Where to Look for Examples ## Where to Look for Examples
- app/Filament/Admin/Pages and app/Filament/Jabali/Pages - app/Filament/Admin/Pages and app/Filament/Jabali/Pages

View File

@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")" JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc54}" JABALI_VERSION="${JABALI_VERSION:-0.9-rc66}"
# Colors # Colors
RED='\033[0;31m' RED='\033[0;31m'
@@ -414,6 +414,7 @@ install_packages() {
wget wget
zip zip
unzip unzip
cron
htop htop
net-tools net-tools
dnsutils dnsutils
@@ -2945,8 +2946,9 @@ EOF
mkdir -p /var/backups/jabali mkdir -p /var/backups/jabali
mkdir -p /var/backups/jabali/cpanel-migrations mkdir -p /var/backups/jabali/cpanel-migrations
mkdir -p /var/backups/jabali/whm-migrations mkdir -p /var/backups/jabali/whm-migrations
mkdir -p /var/backups/jabali/directadmin-migrations
chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations /var/backups/jabali/directadmin-migrations
log "Jabali Panel setup complete" log "Jabali Panel setup complete"
} }
@@ -3019,6 +3021,18 @@ setup_scheduler_cron() {
mkdir -p "$JABALI_DIR/storage/logs" mkdir -p "$JABALI_DIR/storage/logs"
chown -R www-data:www-data "$JABALI_DIR/storage/logs" chown -R www-data:www-data "$JABALI_DIR/storage/logs"
# Ensure crontab command is available
if ! command -v crontab >/dev/null 2>&1; then
warn "crontab command not found, installing cron package..."
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cron || true
fi
if ! command -v crontab >/dev/null 2>&1; then
warn "Unable to configure scheduler: crontab command is still missing"
return
fi
# Ensure cron service is enabled and running # Ensure cron service is enabled and running
if command -v systemctl >/dev/null 2>&1; then if command -v systemctl >/dev/null 2>&1; then
systemctl enable cron >/dev/null 2>&1 || true systemctl enable cron >/dev/null 2>&1 || true
@@ -3029,8 +3043,8 @@ setup_scheduler_cron() {
CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1" CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1"
# Add to www-data's crontab (not root) to avoid permission issues with log files # Add to www-data's crontab (not root) to avoid permission issues with log files
if ! sudo -u www-data crontab -l 2>/dev/null | grep -q "artisan schedule:run"; then if ! crontab -u www-data -l 2>/dev/null | grep -q "artisan schedule:run"; then
(sudo -u www-data crontab -l 2>/dev/null; echo "$CRON_LINE") | sudo -u www-data crontab - (crontab -u www-data -l 2>/dev/null; echo "$CRON_LINE") | crontab -u www-data -
log "Laravel scheduler cron job added" log "Laravel scheduler cron job added"
else else
log "Laravel scheduler cron job already exists" log "Laravel scheduler cron job already exists"
@@ -3616,7 +3630,9 @@ uninstall() {
rm -f /etc/logrotate.d/jabali-users rm -f /etc/logrotate.d/jabali-users
# Remove www-data cron jobs (Laravel scheduler) # Remove www-data cron jobs (Laravel scheduler)
if command -v crontab >/dev/null 2>&1; then
crontab -u www-data -r 2>/dev/null || true crontab -u www-data -r 2>/dev/null || true
fi
log "Configuration files cleaned" log "Configuration files cleaned"

View File

@@ -219,6 +219,20 @@
"Disk Usage": "استخدام القرص", "Disk Usage": "استخدام القرص",
"Display Name": "الاسم المعروض", "Display Name": "الاسم المعروض",
"Document Root": "المجلد الجذر", "Document Root": "المجلد الجذر",
"Documentation": "التوثيق",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "اعثر على إجابات في التوثيق أو تحدث مع روبوت الدعم المدرَّب لدينا. اطّلع على أدلة الإعداد وخطوات استكشاف الأخطاء وأفضل الممارسات.",
"Chat with our AI support bot.": "تحدث مع روبوت الدعم بالذكاء الاصطناعي.",
"GitHub Issues": "مشكلات GitHub",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "أبلغ عن الأخطاء أو اطلب الميزات. أرفق الخطوات والسجلات ولقطات الشاشة لنتمكن من إعادة الإنتاج بسرعة.",
"Open GitHub Issues": "فتح مشكلات GitHub",
"Paid Support": "دعم مدفوع",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "احصل على مساعدة احترافية للترحيل وتحسين الأداء والإصلاحات ذات الأولوية. تتضمن الخطط الإعداد والدعم المخصص.",
"View Support Plans": "عرض خطط الدعم",
"Response Time": "وقت الاستجابة",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "نستجيب عادة خلال 4-8 ساعات. للحوادث الحرجة، استخدم دعم الطوارئ للحصول على استجابة أسرع.",
"Emergency Support": "دعم الطوارئ",
"Open Documentation": "فتح التوثيق",
"Support Chat": "دردشة الدعم",
"Domain": "نطاق", "Domain": "نطاق",
"Domain Name": "اسم النطاق", "Domain Name": "اسم النطاق",
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)", "Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",

View File

@@ -782,6 +782,19 @@
"Display Name": "Display Name", "Display Name": "Display Name",
"Document Root": "Document Root", "Document Root": "Document Root",
"Documentation": "Documentation", "Documentation": "Documentation",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.",
"Chat with our AI support bot.": "Chat with our AI support bot.",
"GitHub Issues": "GitHub Issues",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.",
"Open GitHub Issues": "Open GitHub Issues",
"Paid Support": "Paid Support",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.",
"View Support Plans": "View Support Plans",
"Response Time": "Response Time",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.",
"Emergency Support": "Emergency Support",
"Open Documentation": "Open Documentation",
"Support Chat": "Support Chat",
"Domain": "Domain", "Domain": "Domain",
"Domain Aliases": "Domain Aliases", "Domain Aliases": "Domain Aliases",
"Domain Certificates": "Domain Certificates", "Domain Certificates": "Domain Certificates",

View File

@@ -308,6 +308,20 @@
"Disk Usage": "Uso de disco", "Disk Usage": "Uso de disco",
"Display Name": "Nombre para mostrar", "Display Name": "Nombre para mostrar",
"Document Root": "Raíz del documento", "Document Root": "Raíz del documento",
"Documentation": "Documentación",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encuentra respuestas en nuestra documentación o habla con nuestro bot de soporte entrenado. Explora guías de configuración, pasos de solución de problemas y buenas prácticas.",
"Chat with our AI support bot.": "Chatea con nuestro bot de soporte con IA.",
"GitHub Issues": "Issues de GitHub",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporta errores o solicita funciones. Incluye pasos, registros y capturas de pantalla para que podamos reproducirlo rápido.",
"Open GitHub Issues": "Abrir issues de GitHub",
"Paid Support": "Soporte de pago",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtén asistencia profesional para migraciones, ajustes de rendimiento y correcciones prioritarias. Los planes incluyen incorporación y soporte dedicado.",
"View Support Plans": "Ver planes de soporte",
"Response Time": "Tiempo de respuesta",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos en 4-8 horas. Para incidentes críticos, usa el Soporte de Emergencia para una respuesta más rápida.",
"Emergency Support": "Soporte de emergencia",
"Open Documentation": "Abrir documentación",
"Support Chat": "Chat de soporte",
"Domain": "Dominio", "Domain": "Dominio",
"Domain Configuration": "Configuración de dominio", "Domain Configuration": "Configuración de dominio",
"Domain Count": "Cantidad de dominios", "Domain Count": "Cantidad de dominios",

View File

@@ -220,6 +220,20 @@
"Disk Usage": "Utilisation du disque", "Disk Usage": "Utilisation du disque",
"Display Name": "Nom d'affichage", "Display Name": "Nom d'affichage",
"Document Root": "Racine du document", "Document Root": "Racine du document",
"Documentation": "Documentation",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Trouvez des réponses dans notre documentation ou discutez avec notre bot d'assistance entraîné. Explorez les guides de configuration, les étapes de dépannage et les bonnes pratiques.",
"Chat with our AI support bot.": "Discutez avec notre bot de support IA.",
"GitHub Issues": "Issues GitHub",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Signalez des bugs ou demandez des fonctionnalités. Incluez les étapes, les journaux et des captures d'écran pour une reproduction rapide.",
"Open GitHub Issues": "Ouvrir les issues GitHub",
"Paid Support": "Support payant",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenez une assistance professionnelle pour les migrations, l'optimisation des performances et les correctifs prioritaires. Les plans incluent l'onboarding et un support dédié.",
"View Support Plans": "Voir les plans de support",
"Response Time": "Délai de réponse",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Nous répondons généralement sous 4-8 heures. Pour les incidents critiques, utilisez le support d'urgence pour une réponse plus rapide.",
"Emergency Support": "Support d'urgence",
"Open Documentation": "Ouvrir la documentation",
"Support Chat": "Chat de support",
"Domain": "Domaine", "Domain": "Domaine",
"Domain Name": "Nom de domaine", "Domain Name": "Nom de domaine",
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)", "Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",

View File

@@ -219,6 +219,20 @@
"Disk Usage": "שימוש בדיסק", "Disk Usage": "שימוש בדיסק",
"Display Name": "שם תצוגה", "Display Name": "שם תצוגה",
"Document Root": "תיקיית שורש", "Document Root": "תיקיית שורש",
"Documentation": "תיעוד",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "מצאו תשובות בתיעוד שלנו או דברו עם בוט התמיכה המאומן שלנו. עיינו במדריכי התקנה, שלבי פתרון תקלות ושיטות עבודה מומלצות.",
"Chat with our AI support bot.": "שוחחו עם בוט התמיכה שלנו.",
"GitHub Issues": "Issues של GitHub",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "דווחו על באגים או בקשו פיצ'רים. צרפו שלבים, לוגים וצילומי מסך כדי שנוכל לשחזר במהירות.",
"Open GitHub Issues": "פתח Issues ב-GitHub",
"Paid Support": "תמיכה בתשלום",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "קבלו סיוע מקצועי במיגרציות, שיפור ביצועים ותיקונים בעדיפות גבוהה. התוכניות כוללות קליטה ותמיכה ייעודית.",
"View Support Plans": "צפו בתוכניות התמיכה",
"Response Time": "זמן תגובה",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "אנחנו בדרך כלל מגיבים תוך 4-8 שעות. לאירועים קריטיים, השתמשו בתמיכת חירום לקבלת מענה מהיר יותר.",
"Emergency Support": "תמיכת חירום",
"Open Documentation": "פתח תיעוד",
"Support Chat": "צ'אט תמיכה",
"Domain": "דומיין", "Domain": "דומיין",
"Domain Name": "שם הדומיין", "Domain Name": "שם הדומיין",
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)", "Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",

View File

@@ -219,6 +219,20 @@
"Disk Usage": "Uso de Disco", "Disk Usage": "Uso de Disco",
"Display Name": "Nome de Exibição", "Display Name": "Nome de Exibição",
"Document Root": "Raiz do Documento", "Document Root": "Raiz do Documento",
"Documentation": "Documentação",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encontre respostas na nossa documentação ou fale com o nosso bot de suporte treinado. Explore guias de configuração, etapas de solução de problemas e boas práticas.",
"Chat with our AI support bot.": "Converse com nosso bot de suporte com IA.",
"GitHub Issues": "Issues do GitHub",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporte bugs ou solicite recursos. Inclua passos, logs e capturas de tela para podermos reproduzir rapidamente.",
"Open GitHub Issues": "Abrir issues do GitHub",
"Paid Support": "Suporte pago",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenha assistência profissional para migrações, ajustes de desempenho e correções prioritárias. Os planos incluem onboarding e suporte dedicado.",
"View Support Plans": "Ver planos de suporte",
"Response Time": "Tempo de resposta",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos em 4-8 horas. Para incidentes críticos, use o Suporte de Emergência para uma resposta mais rápida.",
"Emergency Support": "Suporte de emergência",
"Open Documentation": "Abrir documentação",
"Support Chat": "Chat de suporte",
"Domain": "Domínio", "Domain": "Domínio",
"Domain Name": "Nome do Domínio", "Domain Name": "Nome do Domínio",
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)", "Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",

View File

@@ -220,6 +220,20 @@
"Disk Usage": "Использование диска", "Disk Usage": "Использование диска",
"Display Name": "Отображаемое имя", "Display Name": "Отображаемое имя",
"Document Root": "Корневая директория", "Document Root": "Корневая директория",
"Documentation": "Документация",
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Найдите ответы в нашей документации или поговорите с нашим обученным ботом поддержки. Изучите руководства по настройке, шаги по устранению неполадок и лучшие практики.",
"Chat with our AI support bot.": "Чат с нашим AI-ботом поддержки.",
"GitHub Issues": "GitHub Issues",
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Сообщайте о багах или запрашивайте функции. Укажите шаги, логи и скриншоты, чтобы мы могли быстро воспроизвести.",
"Open GitHub Issues": "Открыть GitHub Issues",
"Paid Support": "Платная поддержка",
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Профессиональная помощь по миграциям, оптимизации производительности и приоритетным исправлениям. Планы включают онбординг и выделенную поддержку.",
"View Support Plans": "Посмотреть планы поддержки",
"Response Time": "Время ответа",
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Обычно отвечаем в течение 48 часов. Для критических инцидентов используйте экстренную поддержку для более быстрого ответа.",
"Emergency Support": "Экстренная поддержка",
"Open Documentation": "Открыть документацию",
"Support Chat": "Чат поддержки",
"Domain": "Домен", "Domain": "Домен",
"Domain Name": "Имя домена", "Domain Name": "Имя домена",
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)", "Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",

12
package-lock.json generated
View File

@@ -12,7 +12,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"axios": "^1.11.0", "axios": "^1.13.5",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",
@@ -1181,13 +1181,13 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.2", "version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.11",
"form-data": "^4.0.4", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^1.1.0"
} }
}, },

View File

@@ -11,7 +11,7 @@
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"axios": "^1.11.0", "axios": "^1.13.5",
"concurrently": "^9.0.1", "concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.32", "postcss": "^8.4.32",

View File

@@ -7,3 +7,4 @@
[x-cloak] { [x-cloak] {
display: none; display: none;
} }

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountConfigTable::class, [
'importId' => $this->importId,
], key('directadmin-account-config-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountsTable::class, [
'importId' => $this->importId,
], key('directadmin-accounts-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Admin\Widgets\DirectAdminMigrationStatusTable::class, [
'importId' => $this->importId,
], key('directadmin-migration-status-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,6 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Admin\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))

View File

@@ -0,0 +1,78 @@
<x-filament-panels::page>
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/docs/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('Open Documentation') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-bug-ant"
icon-color="warning"
>
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
<x-filament::button
tag="a"
href="https://github.com/shukiv/jabali-panel/issues"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
color="gray"
>
{{ __('Open GitHub Issues') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-lifebuoy"
icon-color="primary"
>
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/support/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('View Support Plans') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-clock"
icon-color="gray"
compact
>
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/emergency/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
color="warning"
>
{{ __('Emergency Support') }}
</x-filament::button>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@@ -0,0 +1,4 @@
@livewire(\App\Filament\Jabali\Widgets\DirectAdminMigrationStatusTable::class, [
'importId' => $this->importId,
], key('directadmin-self-migration-status-table-' . ($this->importId ?? 'new')))

View File

@@ -0,0 +1,6 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
<x-filament-actions::modals />
</x-filament-panels::page>

View File

@@ -1,4 +1,47 @@
<x-filament-panels::page> <x-filament-panels::page>
<style>
/* Compact spacing for File Manager rows */
#file-dropzone .fi-ta-text:not(.fi-inline) {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
}
#file-dropzone .fi-ta-text-item {
line-height: 1.05rem !important;
}
#file-dropzone .fi-ta-record-content-ctn {
gap: 0.25rem !important;
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
}
#file-dropzone .fi-ta-record-checkbox {
margin-top: 0.2rem !important;
margin-bottom: 0.2rem !important;
}
#file-dropzone td.fi-ta-cell.fi-ta-selection-cell,
#file-dropzone td.fi-ta-cell.fi-ta-group-selection-cell {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
}
#file-dropzone td.fi-ta-cell:has(.fi-ta-actions) {
padding-top: 0.2rem !important;
padding-bottom: 0.2rem !important;
}
#file-dropzone .fi-ta-actions {
gap: 0.35rem !important;
}
#file-dropzone .fi-ta-actions .fi-btn,
#file-dropzone .fi-ta-actions .fi-icon-btn {
min-height: 1.65rem !important;
}
</style>
{{-- Warning Banner --}} {{-- Warning Banner --}}
<x-filament::section <x-filament::section
icon="heroicon-o-exclamation-triangle" icon="heroicon-o-exclamation-triangle"

View File

@@ -1,35 +0,0 @@
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Time') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Category') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Action') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Description') }}</th>
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('IP') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@forelse($this->getActivityLogs() as $log)
<tr>
<td class="px-4 py-3 fi-section-header-description">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->category }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->action }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->description }}</td>
<td class="px-4 py-3 fi-section-header-description">{{ $log->ip_address }}</td>
</tr>
@empty
<tr>
<td colspan="5" class="px-4 py-6 text-center fi-section-header-description">
{{ __('No activity recorded yet.') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-filament::section>

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Jabali\Pages\CpanelMigration::class, [], key('migration-cpanel'))

View File

@@ -0,0 +1,2 @@
@livewire(\App\Filament\Jabali\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))

View File

@@ -0,0 +1,4 @@
<x-filament-panels::page>
{{ $this->migrationForm }}
</x-filament-panels::page>

View File

@@ -0,0 +1,78 @@
<x-filament-panels::page>
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/docs/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('Open Documentation') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-bug-ant"
icon-color="warning"
>
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
<x-filament::button
tag="a"
href="https://github.com/shukiv/jabali-panel/issues"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
color="gray"
>
{{ __('Open GitHub Issues') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-lifebuoy"
icon-color="primary"
>
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/support/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
>
{{ __('View Support Plans') }}
</x-filament::button>
</x-filament::section>
<x-filament::section
icon="heroicon-o-clock"
icon-color="gray"
compact
>
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
<x-filament::button
tag="a"
href="https://jabali-panel.com/emergency/"
target="_blank"
rel="noopener"
icon="heroicon-o-arrow-top-right-on-square"
color="warning"
>
{{ __('Emergency Support') }}
</x-filament::button>
</x-filament::section>
</div>
</x-filament-panels::page>

View File

@@ -1,33 +1,53 @@
@php @php
$jabaliVersion = '1.0.1'; $jabaliVersion = 'unknown';
$versionFile = base_path('VERSION'); $versionFile = base_path('VERSION');
if (file_exists($versionFile)) { if (file_exists($versionFile)) {
$content = file_get_contents($versionFile); $content = file_get_contents($versionFile);
if (preg_match('/VERSION=(.+)/', $content, $matches)) { if (preg_match('/^VERSION=(.+)$/m', $content, $matches)) {
$jabaliVersion = trim($matches[1]); $jabaliVersion = trim($matches[1]);
} }
} }
@endphp @endphp
<style>
.dark .jabali-footer-logo { filter: invert(1) brightness(2); } <footer class="mt-auto border-t border-gray-200/20 px-6 py-5 dark:border-white/10">
</style> <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div style="border-top: 1px solid rgba(128,128,128,0.1); padding: 20px 24px; margin-top: auto;"> <div class="flex items-center gap-3">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;"> <img
<div style="display: flex; align-items: center; gap: 12px;"> src="{{ asset('images/jabali_logo.svg') }}"
<img src="{{ asset('images/jabali_logo.svg') }}" alt="Jabali" style="height: 32px; width: 32px;" class="jabali-footer-logo"> alt="{{ __('Jabali') }}"
<div> class="h-8 w-8 dark:filter dark:invert dark:brightness-200"
<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>
<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>
<div style="display: flex; align-items: center; gap: 16px; font-size: 13px;" class="text-gray-500 dark:text-gray-400"> </div>
<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> <div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
GitHub <x-filament::link
</a> tag="a"
<span style="color: rgba(128,128,128,0.3);"></span> 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>© {{ 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>
</div> <x-filament::badge size="sm" color="gray">
v{{ $jabaliVersion }}
</x-filament::badge>
</div> </div>
</div> </div>
</footer>

View File

@@ -25,13 +25,26 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
Cache::forget('phpmyadmin_token_'.$token); Cache::forget('phpmyadmin_token_'.$token);
return response()->json($data); return response()->json($data);
}); })->middleware('throttle:internal-api');
$allowInternalRequest = static function (Request $request): bool {
$remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip());
$isLocalRequest = in_array($remoteAddr, ['127.0.0.1', '::1'], true);
$configuredToken = trim((string) config('app.internal_api_token', ''));
$providedToken = trim((string) (
$request->header('X-Jabali-Internal-Token')
?? $request->input('internal_token')
?? ''
));
$hasValidToken = $configuredToken !== '' && $providedToken !== '' && hash_equals($configuredToken, $providedToken);
return $isLocalRequest || $hasValidToken;
};
// Internal API for jabali-cache WordPress plugin // Internal API for jabali-cache WordPress plugin
Route::post('/internal/page-cache', function (Request $request) { Route::post('/internal/page-cache', function (Request $request) use ($allowInternalRequest) {
// Only allow requests from localhost if (! $allowInternalRequest($request)) {
$clientIp = $request->ip();
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -68,7 +81,7 @@ Route::post('/internal/page-cache', function (Request $request) {
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) { if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
$authKey = $matches[1]; $authKey = $matches[1];
$expectedSecret = substr(md5($authKey), 0, 32); $expectedSecret = substr(md5($authKey), 0, 32);
if ($secret !== $expectedSecret) { if (! hash_equals($expectedSecret, $secret)) {
return response()->json(['error' => 'Invalid secret'], 401); return response()->json(['error' => 'Invalid secret'], 401);
} }
} else { } else {
@@ -94,13 +107,11 @@ Route::post('/internal/page-cache', function (Request $request) {
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500); return response()->json(['error' => $e->getMessage()], 500);
} }
}); })->middleware('throttle:internal-api');
// Internal API for smart page cache purging (called by jabali-cache WordPress plugin) // Internal API for smart page cache purging (called by jabali-cache WordPress plugin)
Route::post('/internal/page-cache-purge', function (Request $request) { Route::post('/internal/page-cache-purge', function (Request $request) use ($allowInternalRequest) {
// Only allow requests from localhost if (! $allowInternalRequest($request)) {
$clientIp = $request->ip();
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
return response()->json(['error' => 'Forbidden'], 403); return response()->json(['error' => 'Forbidden'], 403);
} }
@@ -137,7 +148,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) { if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
$authKey = $matches[1]; $authKey = $matches[1];
$expectedSecret = substr(md5($authKey), 0, 32); $expectedSecret = substr(md5($authKey), 0, 32);
if ($secret !== $expectedSecret) { if (! hash_equals($expectedSecret, $secret)) {
return response()->json(['error' => 'Invalid secret'], 401); return response()->json(['error' => 'Invalid secret'], 401);
} }
} else { } else {
@@ -164,9 +175,12 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500); return response()->json(['error' => $e->getMessage()], 500);
} }
}); })->middleware('throttle:internal-api');
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class); Route::post('/webhooks/git/{deployment}', GitWebhookController::class)
->middleware('throttle:git-webhooks');
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class)
->middleware('throttle:git-webhooks');
Route::middleware(['auth:sanctum', 'abilities:automation']) Route::middleware(['auth:sanctum', 'abilities:automation'])
->prefix('automation') ->prefix('automation')

493
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,493 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config.toml}"
# Capture env overrides before we assign defaults so config.toml can sit between
# defaults and environment: CLI > env > config > defaults.
ENV_DEPLOY_HOST="${DEPLOY_HOST-}"
ENV_DEPLOY_USER="${DEPLOY_USER-}"
ENV_DEPLOY_PATH="${DEPLOY_PATH-}"
ENV_WWW_USER="${WWW_USER-}"
ENV_NPM_CACHE_DIR="${NPM_CACHE_DIR-}"
ENV_GITEA_REMOTE="${GITEA_REMOTE-}"
ENV_GITEA_URL="${GITEA_URL-}"
ENV_GITHUB_REMOTE="${GITHUB_REMOTE-}"
ENV_GITHUB_URL="${GITHUB_URL-}"
ENV_PUSH_BRANCH="${PUSH_BRANCH-}"
DEPLOY_HOST="192.168.100.50"
DEPLOY_USER="root"
DEPLOY_PATH="/var/www/jabali"
WWW_USER="www-data"
NPM_CACHE_DIR=""
GITEA_REMOTE="gitea"
GITEA_URL=""
GITHUB_REMOTE="origin"
GITHUB_URL=""
PUSH_BRANCH=""
trim_ws() {
local s="${1:-}"
s="$(echo "$s" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
printf '%s' "$s"
}
toml_unquote() {
local v
v="$(trim_ws "${1:-}")"
# Only accept simple double-quoted strings, booleans, or integers.
if [[ ${#v} -ge 2 && "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then
printf '%s' "${v:1:${#v}-2}"
return 0
fi
if [[ "$v" =~ ^(true|false)$ ]]; then
printf '%s' "$v"
return 0
fi
if [[ "$v" =~ ^-?[0-9]+$ ]]; then
printf '%s' "$v"
return 0
fi
return 1
}
load_config_toml() {
local file section line key raw value
file="$1"
section=""
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
# Strip comments and whitespace.
line="${line%%#*}"
line="$(trim_ws "$line")"
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^\[([A-Za-z0-9_.-]+)\]$ ]]; then
section="${BASH_REMATCH[1]}"
continue
fi
[[ "$section" == "deploy" ]] || continue
if [[ "$line" =~ ^([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
raw="${BASH_REMATCH[2]}"
value=""
if ! value="$(toml_unquote "$raw")"; then
continue
fi
case "$key" in
host) DEPLOY_HOST="$value" ;;
user) DEPLOY_USER="$value" ;;
path) DEPLOY_PATH="$value" ;;
www_user) WWW_USER="$value" ;;
npm_cache_dir) NPM_CACHE_DIR="$value" ;;
gitea_remote) GITEA_REMOTE="$value" ;;
gitea_url) GITEA_URL="$value" ;;
github_remote) GITHUB_REMOTE="$value" ;;
github_url) GITHUB_URL="$value" ;;
push_branch) PUSH_BRANCH="$value" ;;
esac
fi
done < "$file"
}
load_config_toml "$CONFIG_FILE"
# Apply environment overrides on top of config.
if [[ -n "${ENV_DEPLOY_HOST:-}" ]]; then DEPLOY_HOST="$ENV_DEPLOY_HOST"; fi
if [[ -n "${ENV_DEPLOY_USER:-}" ]]; then DEPLOY_USER="$ENV_DEPLOY_USER"; fi
if [[ -n "${ENV_DEPLOY_PATH:-}" ]]; then DEPLOY_PATH="$ENV_DEPLOY_PATH"; fi
if [[ -n "${ENV_WWW_USER:-}" ]]; then WWW_USER="$ENV_WWW_USER"; fi
if [[ -n "${ENV_NPM_CACHE_DIR:-}" ]]; then NPM_CACHE_DIR="$ENV_NPM_CACHE_DIR"; fi
if [[ -n "${ENV_GITEA_REMOTE:-}" ]]; then GITEA_REMOTE="$ENV_GITEA_REMOTE"; fi
if [[ -n "${ENV_GITEA_URL:-}" ]]; then GITEA_URL="$ENV_GITEA_URL"; fi
if [[ -n "${ENV_GITHUB_REMOTE:-}" ]]; then GITHUB_REMOTE="$ENV_GITHUB_REMOTE"; fi
if [[ -n "${ENV_GITHUB_URL:-}" ]]; then GITHUB_URL="$ENV_GITHUB_URL"; fi
if [[ -n "${ENV_PUSH_BRANCH:-}" ]]; then PUSH_BRANCH="$ENV_PUSH_BRANCH"; fi
SKIP_SYNC=0
SKIP_COMPOSER=0
SKIP_NPM=0
SKIP_MIGRATE=0
SKIP_CACHE=0
SKIP_AGENT_RESTART=0
DELETE_REMOTE=0
DRY_RUN=0
SKIP_PUSH=0
PUSH_GITEA=1
PUSH_GITHUB=1
SET_VERSION=""
usage() {
cat <<'EOF'
Usage: scripts/deploy.sh [options]
Options:
--host HOST Remote host (default: 192.168.100.50)
--user USER SSH user (default: root)
--path PATH Remote path (default: /var/www/jabali)
--www-user USER Remote runtime user (default: www-data)
--skip-sync Skip rsync sync step
--skip-composer Skip composer install
--skip-npm Skip npm install/build
--skip-migrate Skip php artisan migrate
--skip-cache Skip cache clear/rebuild
--skip-agent-restart Skip restarting jabali-agent service
--delete Pass --delete to rsync (dangerous)
--dry-run Dry-run rsync only
--skip-push Skip all git push operations
--push-gitea Push current branch to Gitea from deploy server (default: on)
--no-push-gitea Disable Gitea push
--gitea-remote NAME Gitea git remote name (default: gitea)
--gitea-url URL Push to this URL instead of a named remote
--push-github Push current branch to GitHub from deploy server (default: on)
--no-push-github Disable GitHub push
--github-remote NAME GitHub git remote name (default: origin)
--github-url URL Push to this URL instead of a named remote
--version VALUE Set VERSION to a specific value before remote push
-h, --help Show this help
Environment overrides:
CONFIG_FILE points to a TOML file (default: ./config.toml). The script reads [deploy] keys.
CONFIG_FILE, DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--host)
DEPLOY_HOST="$2"
shift 2
;;
--user)
DEPLOY_USER="$2"
shift 2
;;
--path)
DEPLOY_PATH="$2"
shift 2
;;
--www-user)
WWW_USER="$2"
shift 2
;;
--skip-sync)
SKIP_SYNC=1
shift
;;
--skip-composer)
SKIP_COMPOSER=1
shift
;;
--skip-npm)
SKIP_NPM=1
shift
;;
--skip-migrate)
SKIP_MIGRATE=1
shift
;;
--skip-cache)
SKIP_CACHE=1
shift
;;
--skip-agent-restart)
SKIP_AGENT_RESTART=1
shift
;;
--delete)
DELETE_REMOTE=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--skip-push)
SKIP_PUSH=1
PUSH_GITEA=0
PUSH_GITHUB=0
shift
;;
--push-gitea)
PUSH_GITEA=1
shift
;;
--no-push-gitea)
PUSH_GITEA=0
shift
;;
--gitea-remote)
GITEA_REMOTE="$2"
shift 2
;;
--gitea-url)
GITEA_URL="$2"
shift 2
;;
--push-github)
PUSH_GITHUB=1
shift
;;
--no-push-github)
PUSH_GITHUB=0
shift
;;
--github-remote)
GITHUB_REMOTE="$2"
shift 2
;;
--github-url)
GITHUB_URL="$2"
shift 2
;;
--version)
SET_VERSION="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
ensure_remote_git_clean() {
local status_output
status_output="$(remote_run "if [[ ! -d \"$DEPLOY_PATH/.git\" ]]; then echo '__NO_GIT__'; exit 0; fi; cd \"$DEPLOY_PATH\" && git status --porcelain")"
if [[ "$status_output" == "__NO_GIT__" ]]; then
echo "Remote path is not a git repository: $DEPLOY_PATH"
exit 1
fi
if [[ -n "$status_output" ]]; then
echo "Remote git worktree is dirty at $DEPLOY_PATH. Commit or stash remote changes first."
echo "$status_output"
exit 1
fi
}
remote_commit_and_push() {
local local_head push_branch
local_head="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
if [[ -n "$PUSH_BRANCH" ]]; then
push_branch="$PUSH_BRANCH"
else
push_branch="$(remote_run "cd \"$DEPLOY_PATH\" && git rev-parse --abbrev-ref HEAD")"
if [[ -z "$push_branch" || "$push_branch" == "HEAD" ]]; then
push_branch="main"
fi
fi
ssh -o StrictHostKeyChecking=no "$REMOTE" \
DEPLOY_PATH="$DEPLOY_PATH" \
PUSH_BRANCH="$push_branch" \
PUSH_GITEA="$PUSH_GITEA" \
PUSH_GITHUB="$PUSH_GITHUB" \
GITEA_REMOTE="$GITEA_REMOTE" \
GITEA_URL="$GITEA_URL" \
GITHUB_REMOTE="$GITHUB_REMOTE" \
GITHUB_URL="$GITHUB_URL" \
SET_VERSION="$SET_VERSION" \
LOCAL_HEAD="$local_head" \
bash -s <<'EOF'
set -euo pipefail
cd "$DEPLOY_PATH"
if [[ ! -d .git ]]; then
echo "Remote path is not a git repository: $DEPLOY_PATH" >&2
exit 1
fi
git config --global --add safe.directory "$DEPLOY_PATH" >/dev/null 2>&1 || true
if ! git config user.name >/dev/null; then
git config user.name "Jabali Deploy"
fi
if ! git config user.email >/dev/null; then
git config user.email "root@$(hostname -f 2>/dev/null || hostname)"
fi
current="$(sed -n 's/^VERSION=//p' VERSION || true)"
if [[ -z "$current" ]]; then
echo "VERSION file missing or invalid on remote." >&2
exit 1
fi
if [[ -n "${SET_VERSION:-}" ]]; then
new="$SET_VERSION"
else
if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
base="${BASH_REMATCH[1]}"
num="${BASH_REMATCH[2]}"
if [[ -z "$num" ]]; then
num=1
else
num=$((num + 1))
fi
new="${base}${num}"
elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
else
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." >&2
exit 1
fi
fi
if [[ "$new" == "$current" ]]; then
echo "VERSION is already '$current'. Use --version to set a new value." >&2
exit 1
fi
printf 'VERSION=%s\n' "$new" > VERSION
sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh
git add -A
if git diff --cached --quiet; then
echo "No changes detected after sync/version bump; skipping commit."
else
git commit -m "Deploy sync from ${LOCAL_HEAD} (v${new})"
fi
if [[ "$PUSH_GITEA" -eq 1 ]]; then
if [[ -n "$GITEA_URL" ]]; then
git push "$GITEA_URL" "$PUSH_BRANCH"
else
git push "$GITEA_REMOTE" "$PUSH_BRANCH"
fi
fi
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
if [[ -n "$GITHUB_URL" ]]; then
git push "$GITHUB_URL" "$PUSH_BRANCH"
else
git push "$GITHUB_REMOTE" "$PUSH_BRANCH"
fi
fi
EOF
}
rsync_project() {
local -a rsync_opts
rsync_opts=(-az --info=progress2)
if [[ "$DELETE_REMOTE" -eq 1 ]]; then
rsync_opts+=(--delete)
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
rsync_opts+=(--dry-run)
fi
rsync "${rsync_opts[@]}" \
--exclude ".git/" \
--exclude "node_modules/" \
--exclude "vendor/" \
--exclude "storage/" \
--exclude "bootstrap/cache/" \
--exclude "public/build/" \
--exclude ".env" \
--exclude ".env.*" \
--exclude "database/*.sqlite" \
--exclude "database/*.sqlite-wal" \
--exclude "database/*.sqlite-shm" \
"$ROOT_DIR/" \
"${REMOTE}:${DEPLOY_PATH}/"
}
remote_run() {
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc '$1'"
}
remote_run_www() {
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc 'cd \"$DEPLOY_PATH\" && sudo -u \"$WWW_USER\" -H bash -lc \"$1\"'"
}
ensure_remote_permissions() {
local parent_dir
parent_dir="$(dirname "$DEPLOY_PATH")"
if [[ -z "$NPM_CACHE_DIR" ]]; then
NPM_CACHE_DIR="${parent_dir}/.npm"
fi
remote_run "mkdir -p \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
remote_run "chown -R \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
remote_run "if [[ -f \"$DEPLOY_PATH/auth.json\" ]]; then chown \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/auth.json\" && chmod 600 \"$DEPLOY_PATH/auth.json\"; fi"
}
echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
if [[ "$DRY_RUN" -eq 0 && "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
echo "Validating remote git worktree..."
ensure_remote_git_clean
fi
if [[ "$SKIP_SYNC" -eq 0 ]]; then
echo "Syncing project files..."
rsync_project
fi
if [[ "$DRY_RUN" -eq 1 ]]; then
echo "Dry run complete. No remote commands executed."
exit 0
fi
if [[ "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
echo "Committing and pushing from ${REMOTE}..."
remote_commit_and_push
fi
echo "Ensuring remote permissions..."
ensure_remote_permissions
if [[ "$SKIP_COMPOSER" -eq 0 ]]; then
echo "Installing composer dependencies..."
remote_run_www "composer install --no-interaction --prefer-dist --optimize-autoloader"
fi
if [[ "$SKIP_NPM" -eq 0 ]]; then
echo "Building frontend assets..."
remote_run_www "npm ci"
remote_run_www "npm run build"
fi
if [[ "$SKIP_MIGRATE" -eq 0 ]]; then
echo "Running migrations..."
remote_run_www "php artisan migrate --force"
fi
if [[ "$SKIP_CACHE" -eq 0 ]]; then
echo "Refreshing caches..."
remote_run_www "php artisan optimize:clear"
remote_run_www "php artisan config:cache"
remote_run_www "php artisan route:cache"
remote_run_www "php artisan view:cache"
fi
if [[ "$SKIP_AGENT_RESTART" -eq 0 ]]; then
echo "Restarting jabali-agent service..."
remote_run "if systemctl list-unit-files jabali-agent.service --no-legend 2>/dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi"
fi
echo "Deploy complete."

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace Tests\Feature;
use App\Jobs\RunGitDeployment;
use App\Models\Domain;
use App\Models\GitDeployment;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class ApiSecurityHardeningTest extends TestCase
{
use RefreshDatabase;
public function test_git_webhook_rejects_unsigned_request_on_tokenless_route(): void
{
Bus::fake();
$deployment = $this->createDeployment();
$response = $this->postJson("/api/webhooks/git/{$deployment->id}", ['ref' => 'refs/heads/main']);
$response->assertStatus(403);
Bus::assertNotDispatched(RunGitDeployment::class);
}
public function test_git_webhook_accepts_hmac_signature(): void
{
Bus::fake();
$deployment = $this->createDeployment();
$payload = ['ref' => 'refs/heads/main'];
$signature = hash_hmac('sha256', (string) json_encode($payload), $deployment->secret_token);
$response = $this
->withHeader('X-Jabali-Signature', $signature)
->postJson("/api/webhooks/git/{$deployment->id}", $payload);
$response->assertStatus(200);
Bus::assertDispatched(RunGitDeployment::class);
}
public function test_git_webhook_accepts_legacy_token_route(): void
{
Bus::fake();
$deployment = $this->createDeployment();
$response = $this->postJson(
"/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}",
['ref' => 'refs/heads/main']
);
$response->assertStatus(200);
Bus::assertDispatched(RunGitDeployment::class);
}
public function test_internal_api_rejects_non_local_without_internal_token(): void
{
config()->set('app.internal_api_token', null);
$response = $this
->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
->postJson('/api/internal/page-cache', []);
$response->assertStatus(403);
}
public function test_internal_api_allows_non_local_with_internal_token(): void
{
config()->set('app.internal_api_token', 'test-internal-token');
$response = $this
->withServerVariables(['REMOTE_ADDR' => '203.0.113.10'])
->withHeader('X-Jabali-Internal-Token', 'test-internal-token')
->postJson('/api/internal/page-cache', []);
$response->assertStatus(400);
$response->assertJson(['error' => 'Domain is required']);
}
private function createDeployment(): GitDeployment
{
$user = User::factory()->create();
$domain = Domain::factory()->for($user)->create();
return GitDeployment::create([
'user_id' => $user->id,
'domain_id' => $domain->id,
'repo_url' => 'https://example.com/repo.git',
'branch' => 'main',
'deploy_path' => '/home/'.$user->username.'/domains/'.$domain->domain.'/public_html',
'auto_deploy' => true,
'secret_token' => 'test-secret-token-1234567890',
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Filament;
use App\Filament\Admin\Pages\Support as AdminSupport;
use App\Filament\Jabali\Pages\Support as UserSupport;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class SupportPagesTest extends TestCase
{
use RefreshDatabase;
public function test_admin_support_page_renders_support_links(): void
{
$admin = User::factory()->admin()->create();
$this->actingAs($admin);
Livewire::test(AdminSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('GitHub Issues')
->assertSee('Paid Support');
}
public function test_user_support_page_renders_support_links(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Livewire::test(UserSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('GitHub Issues')
->assertSee('Paid Support');
}
}

View File

@@ -27,7 +27,7 @@ class ServerChartsWidgetTest extends TestCase
$this->assertArrayHasKey('disk', $history); $this->assertArrayHasKey('disk', $history);
$this->assertArrayHasKey('/', $history['disk']); $this->assertArrayHasKey('/', $history['disk']);
$this->assertArrayHasKey('/boot', $history['disk']); $this->assertArrayHasKey('/boot', $history['disk']);
$this->assertCount(5, $history['labels']); $this->assertCount(30, $history['labels']);
$this->assertCount(count($history['labels']), $history['swap']); $this->assertCount(count($history['labels']), $history['swap']);
$this->assertCount(count($history['labels']), $history['iowait']); $this->assertCount(count($history['labels']), $history['iowait']);
} }