Compare commits

...

24 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
70 changed files with 5353 additions and 2356 deletions

View File

@@ -29,6 +29,7 @@ SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
# SESSION_SECURE_COOKIE=true
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -59,4 +60,13 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
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}"

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ CLAUDE.md
/jabali-panel_*.deb
/jabali-deps_*.deb
.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
**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

View File

@@ -17,6 +17,7 @@ Rules and behavior for automated agents working on Jabali.
- Do not push unless the user explicitly asks.
- Bump `VERSION` before every push.
- Keep `install.sh` version fallback in sync with `VERSION`.
- Push to GitHub from `root@192.168.100.50`.
## Operational
- 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.
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.
@@ -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
- 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
GitHub install:
@@ -141,6 +160,13 @@ Service stack (single-node default):
- PTR (reverse DNS) for mail hostname
- 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
```

View File

@@ -1 +1 @@
VERSION=0.9-rc57
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
{
protected $signature = 'import:process {import_id : The server import ID to process}';
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
private ?AgentClient $agent = null;
@@ -27,8 +28,9 @@ class ImportProcessCommand extends Command
$importId = (int) $this->argument('import_id');
$import = ServerImport::with('accounts')->find($importId);
if (!$import) {
if (! $import) {
$this->error("Import not found: $importId");
return 1;
}
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
'current_task' => null,
]);
$import->addError('No accounts selected for import');
return 1;
}
@@ -67,8 +70,8 @@ class ImportProcessCommand extends Command
'status' => 'failed',
'error' => $e->getMessage(),
]);
$account->addLog("Import failed: " . $e->getMessage());
$import->addError("Account {$account->source_username}: " . $e->getMessage());
$account->addLog('Import failed: '.$e->getMessage());
$import->addError("Account {$account->source_username}: ".$e->getMessage());
}
}
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
'completed_at' => now(),
'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;
}
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
private function getAgent(): AgentClient
{
if ($this->agent === null) {
$this->agent = new AgentClient();
$this->agent = new AgentClient;
}
return $this->agent;
}
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
if ($account->main_domain) {
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
$this->createDomains($account, $user);
$account->addLog("Created domains");
$account->addLog('Created domains');
}
// Step 3: Import files
if ($options['files'] ?? true) {
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
$this->importFiles($import, $account, $user);
$account->addLog("Files imported");
$account->addLog('Files imported');
}
// 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]);
$this->importDatabases($import, $account, $user);
$account->addLog("Databases imported");
$account->addLog('Databases imported');
}
// 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]);
$this->importEmails($import, $account, $user);
$account->addLog("Email accounts imported");
$account->addLog('Email accounts imported');
}
$account->update([
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
'progress' => 100,
'current_task' => null,
]);
$account->addLog("Import completed successfully");
$account->addLog('Import completed successfully');
}
private function createUser(ServerImportAccount $account): User
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
$existingUser = User::where('username', $account->target_username)->first();
if ($existingUser) {
$account->addLog("User already exists: {$account->target_username}");
return $existingUser;
}
@@ -179,8 +184,8 @@ class ImportProcessCommand extends Command
// Create user via agent
$result = $this->getAgent()->createUser($account->target_username, $password);
if (!($result['success'] ?? false)) {
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
if (! ($result['success'] ?? false)) {
throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
}
// Create user in database
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
'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;
}
@@ -201,19 +206,19 @@ class ImportProcessCommand extends Command
// Create main domain
if ($account->main_domain) {
$existingDomain = Domain::where('domain', $account->main_domain)->first();
if (!$existingDomain) {
if (! $existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
if ($result['success'] ?? false) {
Domain::create([
'domain' => $account->main_domain,
'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,
]);
$account->addLog("Created main domain: {$account->main_domain}");
} else {
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
$account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
}
} else {
$account->addLog("Main domain already exists: {$account->main_domain}");
@@ -223,14 +228,14 @@ class ImportProcessCommand extends Command
// Create addon domains
foreach ($account->addon_domains ?? [] as $domain) {
$existingDomain = Domain::where('domain', $domain)->first();
if (!$existingDomain) {
if (! $existingDomain) {
$result = $this->getAgent()->domainCreate($user->username, $domain);
if ($result['success'] ?? false) {
Domain::create([
'domain' => $domain,
'user_id' => $user->id,
'document_root' => "/home/{$user->username}/domains/{$domain}/public",
'document_root' => "/home/{$user->username}/domains/{$domain}/public_html",
'is_active' => true,
]);
$account->addLog("Created addon domain: {$domain}");
@@ -243,31 +248,38 @@ class ImportProcessCommand extends Command
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("File import skipped - not a backup file import");
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog('File import skipped - not a backup file import');
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
$account->addLog("Warning: Backup file not found");
$backupPath = $this->resolveBackupFullPath($import);
if (! $backupPath) {
$account->addLog('Warning: Backup file not found');
return;
}
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
$account->addLog("Warning: Failed to create extraction directory");
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
if (! mkdir($extractDir, 0755, true)) {
$account->addLog('Warning: Failed to create extraction directory');
return;
}
try {
$username = $account->source_username;
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
if ($import->source_type === 'cpanel') {
// 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";
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract backup archive');
}
// Find extracted files
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
@@ -278,19 +290,22 @@ class ImportProcessCommand extends Command
// Copy public_html to the domain
$publicHtml = "$homeDir/public_html";
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)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . 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');
$account->addLog("Copied public_html to {$account->main_domain}");
}
}
}
} else {
// 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";
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
}
// Find domain directories
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
@@ -301,10 +316,10 @@ class ImportProcessCommand extends Command
$publicHtml = "$domainDir/public_html";
if (is_dir($publicHtml)) {
$destDir = "/home/{$user->username}/domains/{$domain}/public";
$destDir = "/home/{$user->username}/domains/{$domain}/public_html";
if (is_dir($destDir)) {
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . 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');
$account->addLog("Copied files for domain: {$domain}");
}
}
@@ -312,67 +327,115 @@ class ImportProcessCommand extends Command
}
} finally {
// Cleanup
exec("rm -rf " . escapeshellarg($extractDir));
exec('rm -rf '.escapeshellarg($extractDir));
}
}
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
{
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
$account->addLog("Database import skipped - not a backup file import");
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
$account->addLog('Database import skipped - not a backup file import');
return;
}
$backupPath = Storage::disk('local')->path($import->backup_path);
if (!file_exists($backupPath)) {
$backupPath = $this->resolveBackupFullPath($import);
if (! $backupPath) {
return;
}
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
if (!mkdir($extractDir, 0755, true)) {
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_".time();
if (! mkdir($extractDir, 0755, true)) {
return;
}
try {
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
// Extract MySQL dumps
if ($import->source_type === 'cpanel') {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
} else {
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
" --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
}
exec($cmd, $output, $code);
if ($code !== 0) {
$account->addLog('Warning: Failed to extract database dumps from backup archive');
}
// Find SQL files
$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) {
$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
$newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
$newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
// Create database via agent
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
if ($result['success'] ?? false) {
// Import data
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
exec($cmd, $importOutput, $importCode);
$sqlToImport = $sqlFile;
$tmpSql = null;
if ($importCode === 0) {
$account->addLog("Imported database: {$newDbName}");
} else {
$account->addLog("Warning: Database created but import failed: {$newDbName}");
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
$cmd = 'mysql '.escapeshellarg($newDbName).' < '.escapeshellarg($sqlToImport).' 2>&1';
exec($cmd, $importOutput, $importCode);
if ($importCode === 0) {
$account->addLog("Imported database: {$newDbName}");
} else {
$account->addLog("Warning: Database created but import failed: {$newDbName}");
}
} finally {
if ($tmpSql && file_exists($tmpSql)) {
@unlink($tmpSql);
}
}
} else {
$account->addLog("Warning: Failed to create database: {$newDbName}");
}
}
} 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("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,6 +14,7 @@ use Filament\Actions\Contracts\HasActions;
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\EmbeddedTable;
use Filament\Schemas\Components\Grid;
@@ -94,6 +95,14 @@ class Dashboard extends Page implements HasActions, HasForms
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.'))
->modalWidth('2xl')
->fillForm(function (): array {
$savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', ''));
$savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]);
return [
'admin_email' => $savedPrimaryEmail,
];
})
->form([
Section::make(__('Next Steps'))
->description(__('Here is a quick setup path to get your first site online.'))
@@ -143,13 +152,22 @@ class Dashboard extends Page implements HasActions, HasForms
->email()
->placeholder(__('admin@example.com')),
])
->modalSubmitActionLabel(__("Don't show again"))
->modalSubmitActionLabel(__('Save and close'))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
if ($adminEmail !== '') {
DnsSetting::set('admin_email_recipients', $adminEmail);
}
DnsSetting::set('onboarding_completed', '1');
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
{
return __('Migrate cPanel accounts directly or via WHM');
return __('Migrate cPanel, WHM, or DirectAdmin accounts into Jabali');
}
public function mount(): void
{
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) {
if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
public function updatedActiveTab(string $activeTab): void
{
if (! in_array($activeTab, ['cpanel', 'whm'], true)) {
if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
$this->activeTab = 'cpanel';
}
}
@@ -79,6 +79,11 @@ class Migration extends Page implements HasForms
->schema([
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

@@ -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 bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string
{
return __('cPanel Migration');

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

@@ -83,7 +83,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
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
@@ -162,6 +162,11 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
->rows(2)
->disabled()
->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')
->label(__('Deploy Key'))
->rows(3)
@@ -170,6 +175,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
])
->fillForm(fn (GitDeploymentModel $record): array => [
'webhook_url' => $this->getWebhookUrl($record),
'webhook_secret' => $record->secret_token,
'deploy_key' => $this->getDeployKey(),
]),
Action::make('edit')

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

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\Domain;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -205,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->modalDescription(__('This will create a copy of your site for testing.'))
->modalIcon('heroicon-o-document-duplicate')
->modalIconColor('info')
->form([
TextInput::make('staging_subdomain')
->label(__('Staging Subdomain'))
->prefix('staging-')
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test')
->required()
->alphaNum(),
])
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
->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')
->label(__('Subdomain'))
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
->default('test')
->required(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
->visible(fn (Get $get): bool => $get('staging_target_type') !== 'domain')
->regex('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/')
->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')
->label(__('Push to Production'))
->icon('heroicon-o-arrow-up-tray')
@@ -259,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
);
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
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) {
@@ -894,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 {
$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()
->title(__('Creating Staging Environment...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->send('wp.create_staging', [
'username' => $this->getUsername(),
'site_id' => $siteId,
'subdomain' => 'staging-'.$subdomain,
]);
$result = $this->getAgent()->send('wp.create_staging', $agentPayload);
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()
->title(__('Staging Environment Created'))
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
@@ -1466,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
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,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
{
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);
}

View File

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

View File

@@ -92,7 +92,7 @@ class User extends Authenticatable implements FilamentUser
]);
}
}
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
}
@@ -100,32 +100,37 @@ class User extends Authenticatable implements FilamentUser
$masterUser = $user->username.'_admin';
try {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (class_exists(\mysqli::class)) {
// Use credentials from environment variables
$mysqli = new \mysqli(
config('database.connections.mysql.host', 'localhost'),
config('database.connections.mysql.username'),
config('database.connections.mysql.password')
);
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
if (! $mysqli->connect_error) {
// Use prepared statement to prevent SQL injection
// MySQL doesn't support prepared statements for DROP USER,
// so we validate the username format strictly
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
throw new \Exception('Invalid MySQL username format');
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Escape the username as an additional safety measure
$escapedUser = $mysqli->real_escape_string($masterUser);
$mysqli->query("DROP USER IF EXISTS '{$escapedUser}'@'localhost'");
$mysqli->close();
}
// Delete stored credentials
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Exception $e) {
} catch (\Throwable $e) {
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
}
try {
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
} catch (\Throwable $e) {
\Log::error('Failed to delete stored MySQL credentials: '.$e->getMessage());
}
});
}
@@ -159,28 +164,25 @@ class User extends Authenticatable implements FilamentUser
*/
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 {
$agent = new \App\Services\Agent\AgentClient;
$result = $agent->quotaGet($this->username, '/');
$agent = new \App\Services\Agent\AgentClient(
(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'])) {
return (int) ($result['used_mb'] * 1024 * 1024);
}
} catch (\Exception $e) {
// Fall back to du command
} catch (\Throwable $e) {
\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;
}
$output = shell_exec('du -sb '.escapeshellarg($homeDir).' 2>/dev/null | cut -f1');
return (int) trim($output ?: '0');
return 0;
}
/**

View File

@@ -5,17 +5,18 @@ namespace App\Providers;
use App\Models\Domain;
use App\Observers\DomainObserver;
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\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
}
public function register(): void {}
/**
* Bootstrap any application services.
@@ -24,6 +25,31 @@ class AppServiceProvider extends ServiceProvider
{
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');
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
FilamentAsset::appVersion($appVersion ?: null);

View File

@@ -504,13 +504,19 @@ class AgentClient
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,
'site_id' => $siteId,
'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

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -12,7 +13,24 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->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);
})
->withExceptions(function (Exceptions $exceptions): void {

119
composer.lock generated
View File

@@ -5286,16 +5286,16 @@
},
{
"name": "psy/psysh",
"version": "v0.12.18",
"version": "v0.12.20",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196"
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196",
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196",
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
"shasum": ""
},
"require": {
@@ -5359,9 +5359,9 @@
],
"support": {
"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",
@@ -5981,16 +5981,16 @@
},
{
"name": "symfony/console",
"version": "v7.4.3",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6"
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
"url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
"shasum": ""
},
"require": {
@@ -6055,7 +6055,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.3"
"source": "https://github.com/symfony/console/tree/v7.4.4"
},
"funding": [
{
@@ -6075,7 +6075,7 @@
"type": "tidelift"
}
],
"time": "2025-12-23T14:50:43+00:00"
"time": "2026-01-13T11:36:38+00:00"
},
{
"name": "symfony/css-selector",
@@ -7803,16 +7803,16 @@
},
{
"name": "symfony/process",
"version": "v7.4.3",
"version": "v7.4.5",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f"
"reference": "608476f4604102976d687c483ac63a79ba18cc97"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
"url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
"reference": "608476f4604102976d687c483ac63a79ba18cc97",
"shasum": ""
},
"require": {
@@ -7844,7 +7844,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v7.4.3"
"source": "https://github.com/symfony/process/tree/v7.4.5"
},
"funding": [
{
@@ -7864,7 +7864,7 @@
"type": "tidelift"
}
],
"time": "2025-12-19T10:00:43+00:00"
"time": "2026-01-26T15:07:59+00:00"
},
{
"name": "symfony/routing",
@@ -8040,16 +8040,16 @@
},
{
"name": "symfony/string",
"version": "v8.0.1",
"version": "v8.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc"
"reference": "758b372d6882506821ed666032e43020c4f57194"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc",
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc",
"url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
"reference": "758b372d6882506821ed666032e43020c4f57194",
"shasum": ""
},
"require": {
@@ -8106,7 +8106,7 @@
"utf8"
],
"support": {
"source": "https://github.com/symfony/string/tree/v8.0.1"
"source": "https://github.com/symfony/string/tree/v8.0.4"
},
"funding": [
{
@@ -8126,7 +8126,7 @@
"type": "tidelift"
}
],
"time": "2025-12-01T09:13:36+00:00"
"time": "2026-01-12T12:37:40+00:00"
},
{
"name": "symfony/translation",
@@ -8383,16 +8383,16 @@
},
{
"name": "symfony/var-dumper",
"version": "v7.4.3",
"version": "v7.4.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "7e99bebcb3f90d8721890f2963463280848cba92"
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92",
"reference": "7e99bebcb3f90d8721890f2963463280848cba92",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282",
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282",
"shasum": ""
},
"require": {
@@ -8446,7 +8446,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v7.4.3"
"source": "https://github.com/symfony/var-dumper/tree/v7.4.4"
},
"funding": [
{
@@ -8466,7 +8466,7 @@
"type": "tidelift"
}
],
"time": "2025-12-18T07:04:31+00:00"
"time": "2026-01-01T22:13:48+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@@ -9820,28 +9820,28 @@
},
{
"name": "phpunit/php-file-iterator",
"version": "5.1.0",
"version": "5.1.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
"shasum": ""
},
"require": {
"php": ">=8.2"
},
"require-dev": {
"phpunit/phpunit": "^11.0"
"phpunit/phpunit": "^11.3"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "5.0-dev"
"dev-main": "5.1-dev"
}
},
"autoload": {
@@ -9869,15 +9869,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
"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": [
{
"url": "https://github.com/sebastianbergmann",
"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",
@@ -10065,16 +10077,16 @@
},
{
"name": "phpunit/phpunit",
"version": "11.5.48",
"version": "11.5.53",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89"
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fe3665c15e37140f55aaf658c81a2eb9030b6d89",
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
"shasum": ""
},
"require": {
@@ -10089,18 +10101,19 @@
"phar-io/version": "^3.2.1",
"php": ">=8.2",
"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-text-template": "^4.0.1",
"phpunit/php-timer": "^7.0.1",
"sebastian/cli-parser": "^3.0.2",
"sebastian/code-unit": "^3.0.3",
"sebastian/comparator": "^6.3.2",
"sebastian/comparator": "^6.3.3",
"sebastian/diff": "^6.0.2",
"sebastian/environment": "^7.2.1",
"sebastian/exporter": "^6.3.2",
"sebastian/global-state": "^7.0.2",
"sebastian/object-enumerator": "^6.0.1",
"sebastian/recursion-context": "^6.0.3",
"sebastian/type": "^5.1.3",
"sebastian/version": "^5.0.2",
"staabm/side-effects-detector": "^1.0.5"
@@ -10146,7 +10159,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"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": [
{
@@ -10170,7 +10183,7 @@
"type": "tidelift"
}
],
"time": "2026-01-16T16:26:27+00:00"
"time": "2026-02-10T12:28:25+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -10344,16 +10357,16 @@
},
{
"name": "sebastian/comparator",
"version": "6.3.2",
"version": "6.3.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
"shasum": ""
},
"require": {
@@ -10412,7 +10425,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"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": [
{
@@ -10432,7 +10445,7 @@
"type": "tidelift"
}
],
"time": "2025-08-10T08:07:46+00:00"
"time": "2026-01-24T09:26:40+00:00"
},
{
"name": "sebastian/complexity",
@@ -11346,5 +11359,5 @@
"php": "^8.2"
},
"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'),
],
/*
|--------------------------------------------------------------------------
| 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',
'throw' => false,
],
// Server-wide backups folder (created by install.sh)
'backups' => [
'driver' => 'local',
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
'throw' => false,
],
],
'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.
- 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
- 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
Last updated: 2026-02-09
Last updated: 2026-02-10
## Top-Level Docs
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
@@ -13,6 +13,8 @@ Last updated: 2026-02-09
## Docs Folder
- /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/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/screenshots/README.md - Screenshot generation instructions.
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).

View File

@@ -1,6 +1,6 @@
# Documentation Summary (Jabali Panel)
Last updated: 2026-02-09
Last updated: 2026-02-10
## 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.
@@ -41,7 +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.
- 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.
- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump.
- 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
Debian packaging is supported via scripts:
@@ -53,5 +53,6 @@ mcp-docs-server exposes README, AGENT docs, and changelog through MCP tools for
## Miscellaneous Docs
- 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.
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.

View File

@@ -112,11 +112,19 @@ browser (Ctrl+Shift+R) after deployment.
## Deploy script
The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the
project to a remote server over SSH, then runs composer/npm, migrations, and
cache rebuilds as the web user.
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`).
Defaults (override via flags or env vars):
- Host: `192.168.100.50`
- User: `root`
- Path: `/var/www/jabali`
@@ -137,20 +145,32 @@ scripts/deploy.sh --dry-run
scripts/deploy.sh --skip-npm --skip-cache
```
Push to Git remotes (optional):
Push behavior controls:
```
# Push to Gitea and/or GitHub before deploying
scripts/deploy.sh --push-gitea --push-github
# Deploy only (no push)
scripts/deploy.sh --skip-push
# Push to explicit URLs
scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \
--push-github --github-url git@github.com:shukiv/jabali-panel.git
# 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:
- `--push-gitea` / `--push-github` require a clean worktree.
- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback,
and commits the version bump before pushing.
- 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).

View File

@@ -51,6 +51,7 @@ From /var/www/jabali:
- Do not push unless explicitly asked.
- Bump VERSION before every push.
- Keep install.sh version fallback in sync with VERSION.
- Push to GitHub from `root@192.168.100.50`.
## Where to Look for Examples
- 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
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
fi
JABALI_VERSION="${JABALI_VERSION:-0.9-rc57}"
JABALI_VERSION="${JABALI_VERSION:-0.9-rc66}"
# Colors
RED='\033[0;31m'
@@ -414,6 +414,7 @@ install_packages() {
wget
zip
unzip
cron
htop
net-tools
dnsutils
@@ -2945,8 +2946,9 @@ EOF
mkdir -p /var/backups/jabali
mkdir -p /var/backups/jabali/cpanel-migrations
mkdir -p /var/backups/jabali/whm-migrations
mkdir -p /var/backups/jabali/directadmin-migrations
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"
}
@@ -3019,6 +3021,18 @@ setup_scheduler_cron() {
mkdir -p "$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
if command -v systemctl >/dev/null 2>&1; then
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"
# 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
(sudo -u www-data crontab -l 2>/dev/null; echo "$CRON_LINE") | sudo -u www-data crontab -
if ! crontab -u www-data -l 2>/dev/null | grep -q "artisan schedule:run"; then
(crontab -u www-data -l 2>/dev/null; echo "$CRON_LINE") | crontab -u www-data -
log "Laravel scheduler cron job added"
else
log "Laravel scheduler cron job already exists"
@@ -3616,7 +3630,9 @@ uninstall() {
rm -f /etc/logrotate.d/jabali-users
# Remove www-data cron jobs (Laravel scheduler)
crontab -u www-data -r 2>/dev/null || true
if command -v crontab >/dev/null 2>&1; then
crontab -u www-data -r 2>/dev/null || true
fi
log "Configuration files cleaned"

View File

@@ -220,6 +220,17 @@
"Display Name": "الاسم المعروض",
"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": "نطاق",

View File

@@ -782,6 +782,17 @@
"Display Name": "Display Name",
"Document Root": "Document Root",
"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",

View File

@@ -309,6 +309,17 @@
"Display Name": "Nombre para mostrar",
"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",

View File

@@ -221,6 +221,17 @@
"Display Name": "Nom d'affichage",
"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",

View File

@@ -220,6 +220,17 @@
"Display Name": "שם תצוגה",
"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": "דומיין",

View File

@@ -220,6 +220,17 @@
"Display Name": "Nome de Exibição",
"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",

View File

@@ -221,6 +221,17 @@
"Display Name": "Отображаемое имя",
"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": "Домен",

12
package-lock.json generated
View File

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

View File

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

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

@@ -1,32 +1,78 @@
<x-filament-panels::page>
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
<x-slot name="heading">{{ __('Documentation') }}</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"
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
<x-filament::section
icon="heroicon-o-book-open"
icon-color="primary"
>
{{ __('Open Documentation') }}
</x-filament::button>
</x-filament::section>
<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::section
icon="heroicon-o-chat-bubble-left-right"
icon-color="info"
class="mt-6"
>
<x-slot name="heading">{{ __('Support Chat') }}</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>
<div id="jabali-support-chat"></div>
</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>
@script
<script src="https://portal.jabali-panel.com/js/support-widget.js?v=10" data-api-url="https://portal.jabali-panel.com" data-open="true"></script>
@endscript
<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>
<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 --}}
<x-filament::section
icon="heroicon-o-exclamation-triangle"

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

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

View File

@@ -25,13 +25,26 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
Cache::forget('phpmyadmin_token_'.$token);
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
Route::post('/internal/page-cache', function (Request $request) {
// Only allow requests from localhost
$clientIp = $request->ip();
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
Route::post('/internal/page-cache', function (Request $request) use ($allowInternalRequest) {
if (! $allowInternalRequest($request)) {
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)) {
$authKey = $matches[1];
$expectedSecret = substr(md5($authKey), 0, 32);
if ($secret !== $expectedSecret) {
if (! hash_equals($expectedSecret, $secret)) {
return response()->json(['error' => 'Invalid secret'], 401);
}
} else {
@@ -94,13 +107,11 @@ Route::post('/internal/page-cache', function (Request $request) {
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
});
})->middleware('throttle:internal-api');
// Internal API for smart page cache purging (called by jabali-cache WordPress plugin)
Route::post('/internal/page-cache-purge', function (Request $request) {
// Only allow requests from localhost
$clientIp = $request->ip();
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
Route::post('/internal/page-cache-purge', function (Request $request) use ($allowInternalRequest) {
if (! $allowInternalRequest($request)) {
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)) {
$authKey = $matches[1];
$expectedSecret = substr(md5($authKey), 0, 32);
if ($secret !== $expectedSecret) {
if (! hash_equals($expectedSecret, $secret)) {
return response()->json(['error' => 'Invalid secret'], 401);
}
} else {
@@ -164,9 +175,12 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
} catch (\Exception $e) {
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'])
->prefix('automation')

View File

@@ -3,25 +3,132 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
WWW_USER="${WWW_USER:-www-data}"
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
GITEA_URL="${GITEA_URL:-}"
GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
GITHUB_URL="${GITHUB_URL:-}"
PUSH_BRANCH="${PUSH_BRANCH:-}"
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
PUSH_GITEA=0
PUSH_GITHUB=0
SKIP_PUSH=0
PUSH_GITEA=1
PUSH_GITHUB=1
SET_VERSION=""
usage() {
@@ -38,19 +145,24 @@ Options:
--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
--push-gitea Push current branch to Gitea before deploy
--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 before deploy
--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 push
--version VALUE Set VERSION to a specific value before remote push
-h, --help Show this help
Environment overrides:
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
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
}
@@ -92,6 +204,10 @@ while [[ $# -gt 0 ]]; do
SKIP_CACHE=1
shift
;;
--skip-agent-restart)
SKIP_AGENT_RESTART=1
shift
;;
--delete)
DELETE_REMOTE=1
shift
@@ -100,10 +216,20 @@ while [[ $# -gt 0 ]]; do
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
@@ -116,6 +242,10 @@ while [[ $# -gt 0 ]]; do
PUSH_GITHUB=1
shift
;;
--no-push-github)
PUSH_GITHUB=0
shift
;;
--github-remote)
GITHUB_REMOTE="$2"
shift 2
@@ -142,81 +272,121 @@ done
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
ensure_clean_worktree() {
if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
echo "Working tree is dirty. Commit or stash changes before pushing."
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
}
get_current_version() {
sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION"
}
remote_commit_and_push() {
local local_head push_branch
local_head="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
bump_version() {
local current new base num
current="$(get_current_version)"
if [[ -n "$SET_VERSION" ]]; then
new="$SET_VERSION"
if [[ -n "$PUSH_BRANCH" ]]; then
push_branch="$PUSH_BRANCH"
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))"
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
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly."
exit 1
num=$((num + 1))
fi
fi
if [[ "$new" == "$current" ]]; then
echo "VERSION is already '$current'. Use --version to set a new value."
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
printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION"
perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh"
if [[ "$new" == "$current" ]]; then
echo "VERSION is already '$current'. Use --version to set a new value." >&2
exit 1
fi
git -C "$ROOT_DIR" add VERSION install.sh
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
git -C "$ROOT_DIR" commit -m "Bump VERSION to $new"
fi
}
printf 'VERSION=%s\n' "$new" > VERSION
sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh
prepare_push() {
ensure_clean_worktree
bump_version
}
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
push_remote() {
local label="$1"
local remote_name="$2"
local remote_url="$3"
local target
if [[ -n "$remote_url" ]]; then
target="$remote_url"
if [[ "$PUSH_GITEA" -eq 1 ]]; then
if [[ -n "$GITEA_URL" ]]; then
git push "$GITEA_URL" "$PUSH_BRANCH"
else
if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then
echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote."
exit 1
fi
target="$remote_name"
git push "$GITEA_REMOTE" "$PUSH_BRANCH"
fi
fi
if [[ -z "$PUSH_BRANCH" ]]; then
PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
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
git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH"
fi
EOF
}
rsync_project() {
@@ -253,20 +423,24 @@ 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 [[ "$PUSH_GITEA" -eq 1 ]]; then
prepare_push
echo "Pushing to Gitea..."
push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL"
fi
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
if [[ "$PUSH_GITEA" -ne 1 ]]; then
prepare_push
fi
echo "Pushing to GitHub..."
push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL"
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
@@ -279,6 +453,14 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
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"
@@ -303,4 +485,9 @@ if [[ "$SKIP_CACHE" -eq 0 ]]; then
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

@@ -15,7 +15,7 @@ class SupportPagesTest extends TestCase
{
use RefreshDatabase;
public function test_admin_support_page_renders_docs_and_chat(): void
public function test_admin_support_page_renders_support_links(): void
{
$admin = User::factory()->admin()->create();
@@ -24,10 +24,11 @@ class SupportPagesTest extends TestCase
Livewire::test(AdminSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('jabali-support-chat', false);
->assertSee('GitHub Issues')
->assertSee('Paid Support');
}
public function test_user_support_page_renders_docs_and_chat(): void
public function test_user_support_page_renders_support_links(): void
{
$user = User::factory()->create();
@@ -36,6 +37,7 @@ class SupportPagesTest extends TestCase
Livewire::test(UserSupport::class)
->assertStatus(200)
->assertSee('Open Documentation')
->assertSee('jabali-support-chat', false);
->assertSee('GitHub Issues')
->assertSee('Paid Support');
}
}

View File

@@ -27,7 +27,7 @@ class ServerChartsWidgetTest extends TestCase
$this->assertArrayHasKey('disk', $history);
$this->assertArrayHasKey('/', $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['iowait']);
}