Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e230ac17aa | |||
|
|
7125c535cc | ||
|
|
2dfc139f42 | ||
|
|
52e116e671 | ||
|
|
0c6402604d | ||
| 5d502699ea | |||
| 967df591d6 | |||
| 2bdf7395fc | |||
| c4acf0b658 | |||
| ed5e3f2bda | |||
| 070e46cf77 | |||
| a566a2ae64 | |||
| 1e66f43d4e | |||
| 443b05a677 | |||
| 13685615cb | |||
| e7920366d7 | |||
| 3fa6399b27 | |||
| e22d73eba5 | |||
| a9f8670224 | |||
| 386c759e70 | |||
| c1599f5dd1 | |||
| 6064de6c81 | |||
| f7902105de | |||
| b049d338d8 | |||
| 8573d96719 | |||
| 800e07d2ba | |||
| c6f5b6cab8 | |||
|
|
8acc55a799 | ||
|
|
a5742a3156 |
10
.env.example
10
.env.example
@@ -29,6 +29,7 @@ SESSION_LIFETIME=120
|
|||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
# SESSION_SECURE_COOKIE=true
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -59,4 +60,13 @@ AWS_DEFAULT_REGION=us-east-1
|
|||||||
AWS_BUCKET=
|
AWS_BUCKET=
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
# Comma-separated list of trusted proxies. Use "*" only when intentional.
|
||||||
|
# TRUSTED_PROXIES=127.0.0.1,::1
|
||||||
|
|
||||||
|
# Optional internal API shared token for non-localhost calls.
|
||||||
|
# JABALI_INTERNAL_API_TOKEN=
|
||||||
|
|
||||||
|
# Set to true only when remote panel migration discovery must skip TLS verification.
|
||||||
|
# JABALI_IMPORT_INSECURE_TLS=false
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -22,3 +22,6 @@ CLAUDE.md
|
|||||||
/jabali-panel_*.deb
|
/jabali-panel_*.deb
|
||||||
/jabali-deps_*.deb
|
/jabali-deps_*.deb
|
||||||
.git-credentials
|
.git-credentials
|
||||||
|
|
||||||
|
# Local repository configuration (do not commit)
|
||||||
|
config.toml
|
||||||
|
|||||||
6
.stylelintignore
Normal file
6
.stylelintignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
vendor/
|
||||||
|
node_modules/
|
||||||
|
public/build/
|
||||||
|
public/vendor/
|
||||||
|
public/fonts/
|
||||||
|
public/css/filament/
|
||||||
18
.stylelintrc.json
Normal file
18
.stylelintrc.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"rules": {
|
||||||
|
"at-rule-no-unknown": [
|
||||||
|
true,
|
||||||
|
{
|
||||||
|
"ignoreAtRules": [
|
||||||
|
"tailwind",
|
||||||
|
"apply",
|
||||||
|
"layer",
|
||||||
|
"variants",
|
||||||
|
"responsive",
|
||||||
|
"screen",
|
||||||
|
"theme"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
AGENT.md
9
AGENT.md
@@ -58,6 +58,7 @@ php artisan route:cache # Cache routes
|
|||||||
## Git Workflow
|
## Git Workflow
|
||||||
|
|
||||||
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
|
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
|
||||||
|
**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured).
|
||||||
|
|
||||||
### Version Numbers
|
### Version Numbers
|
||||||
|
|
||||||
@@ -620,7 +621,15 @@ All administrative actions are logged to the `audit_logs` table.
|
|||||||
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
|
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
|
||||||
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop
|
- **MUST be responsive** - All pages must work on mobile, tablet, and desktop
|
||||||
|
|
||||||
|
### Warning Banners
|
||||||
|
|
||||||
|
- Use Filament `Section::make()` for warning banners (no raw HTML).
|
||||||
|
- Always set `->icon('heroicon-o-exclamation-triangle')` and `->iconColor('warning')`.
|
||||||
|
- Keep banners non-collapsible: `->collapsed(false)->collapsible(false)`.
|
||||||
|
- Put the full message in `->description()` and keep the heading short.
|
||||||
|
|
||||||
### Allowed Components
|
### Allowed Components
|
||||||
|
|
||||||
Use these Filament native components exclusively:
|
Use these Filament native components exclusively:
|
||||||
|
|
||||||
| Category | Components |
|
| Category | Components |
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Rules and behavior for automated agents working on Jabali.
|
|||||||
- Do not push unless the user explicitly asks.
|
- Do not push unless the user explicitly asks.
|
||||||
- Bump `VERSION` before every push.
|
- Bump `VERSION` before every push.
|
||||||
- Keep `install.sh` version fallback in sync with `VERSION`.
|
- Keep `install.sh` version fallback in sync with `VERSION`.
|
||||||
|
- Push to GitHub from `root@192.168.100.50`.
|
||||||
|
|
||||||
## Operational
|
## Operational
|
||||||
- If you add dependencies, update both install and uninstall paths.
|
- If you add dependencies, update both install and uninstall paths.
|
||||||
|
|||||||
28
README.md
28
README.md
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server.
|
A modern web hosting control panel for WordPress and general PHP hosting. Jabali focuses on clean multi-tenant isolation, safe automation, and a consistent admin/user experience. It ships with a privileged agent for root-level tasks, built-in mail and DNS management, migrations from common panels, and a security center that keeps critical services in check. The UI is designed to be fast, predictable, and easy to operate on a single server.
|
||||||
|
|
||||||
Version: 0.9-rc51 (release candidate)
|
Version: see `VERSION` (release candidate)
|
||||||
|
|
||||||
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
|
||||||
|
|
||||||
@@ -27,6 +27,25 @@ This is a release candidate. Expect rapid iteration and breaking changes until 1
|
|||||||
- Security center with firewall, Fail2ban, ClamAV, and scanners
|
- Security center with firewall, Fail2ban, ClamAV, and scanners
|
||||||
- Audit logs and admin notifications
|
- Audit logs and admin notifications
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Admin panel:
|
||||||
|
- [Admin Dashboard](docs/screenshots/admin-dashboard.png)
|
||||||
|
- [Admin Server Status](docs/screenshots/admin-server-status.png)
|
||||||
|
- [Admin Server Settings](docs/screenshots/admin-server-settings.png)
|
||||||
|
- [Admin Security](docs/screenshots/admin-security.png)
|
||||||
|
- [Admin Users](docs/screenshots/admin-users.png)
|
||||||
|
- [Admin SSL Manager](docs/screenshots/admin-ssl-manager.png)
|
||||||
|
- [Admin DNS Zones](docs/screenshots/admin-dns-zones.png)
|
||||||
|
- [Admin Backups](docs/screenshots/admin-backups.png)
|
||||||
|
- [Admin Services](docs/screenshots/admin-services.png)
|
||||||
|
|
||||||
|
User panel:
|
||||||
|
- [User Dashboard](docs/screenshots/user-dashboard.png)
|
||||||
|
- [User Domains](docs/screenshots/user-domains.png)
|
||||||
|
- [User Backups](docs/screenshots/user-backups.png)
|
||||||
|
- [User cPanel Migration](docs/screenshots/user-cpanel-migration.png)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
GitHub install:
|
GitHub install:
|
||||||
@@ -141,6 +160,13 @@ Service stack (single-node default):
|
|||||||
- PTR (reverse DNS) for mail hostname
|
- PTR (reverse DNS) for mail hostname
|
||||||
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
|
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies).
|
||||||
|
- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost.
|
||||||
|
- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification.
|
||||||
|
- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256).
|
||||||
|
|
||||||
## Upgrades
|
## Upgrades
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1616
app/Backups.php
1616
app/Backups.php
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ use Illuminate\Support\Str;
|
|||||||
class ImportProcessCommand extends Command
|
class ImportProcessCommand extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'import:process {import_id : The server import ID to process}';
|
protected $signature = 'import:process {import_id : The server import ID to process}';
|
||||||
|
|
||||||
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
|
protected $description = 'Process a server import job (cPanel/DirectAdmin migration)';
|
||||||
|
|
||||||
private ?AgentClient $agent = null;
|
private ?AgentClient $agent = null;
|
||||||
@@ -27,8 +28,9 @@ class ImportProcessCommand extends Command
|
|||||||
$importId = (int) $this->argument('import_id');
|
$importId = (int) $this->argument('import_id');
|
||||||
|
|
||||||
$import = ServerImport::with('accounts')->find($importId);
|
$import = ServerImport::with('accounts')->find($importId);
|
||||||
if (!$import) {
|
if (! $import) {
|
||||||
$this->error("Import not found: $importId");
|
$this->error("Import not found: $importId");
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +45,7 @@ class ImportProcessCommand extends Command
|
|||||||
'current_task' => null,
|
'current_task' => null,
|
||||||
]);
|
]);
|
||||||
$import->addError('No accounts selected for import');
|
$import->addError('No accounts selected for import');
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +70,8 @@ class ImportProcessCommand extends Command
|
|||||||
'status' => 'failed',
|
'status' => 'failed',
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
$account->addLog("Import failed: " . $e->getMessage());
|
$account->addLog('Import failed: '.$e->getMessage());
|
||||||
$import->addError("Account {$account->source_username}: " . $e->getMessage());
|
$import->addError("Account {$account->source_username}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,10 +99,10 @@ class ImportProcessCommand extends Command
|
|||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
'progress' => 100,
|
'progress' => 100,
|
||||||
]);
|
]);
|
||||||
$import->addLog("All accounts imported successfully");
|
$import->addLog('All accounts imported successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Import completed. Success: " . ($totalAccounts - $failedCount) . ", Failed: $failedCount");
|
$this->info('Import completed. Success: '.($totalAccounts - $failedCount).", Failed: $failedCount");
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -107,8 +110,9 @@ class ImportProcessCommand extends Command
|
|||||||
private function getAgent(): AgentClient
|
private function getAgent(): AgentClient
|
||||||
{
|
{
|
||||||
if ($this->agent === null) {
|
if ($this->agent === null) {
|
||||||
$this->agent = new AgentClient();
|
$this->agent = new AgentClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->agent;
|
return $this->agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,28 +136,28 @@ class ImportProcessCommand extends Command
|
|||||||
if ($account->main_domain) {
|
if ($account->main_domain) {
|
||||||
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
|
$account->update(['current_task' => 'Creating domains...', 'progress' => 20]);
|
||||||
$this->createDomains($account, $user);
|
$this->createDomains($account, $user);
|
||||||
$account->addLog("Created domains");
|
$account->addLog('Created domains');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Import files
|
// Step 3: Import files
|
||||||
if ($options['files'] ?? true) {
|
if ($options['files'] ?? true) {
|
||||||
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
|
$account->update(['current_task' => 'Importing files...', 'progress' => 40]);
|
||||||
$this->importFiles($import, $account, $user);
|
$this->importFiles($import, $account, $user);
|
||||||
$account->addLog("Files imported");
|
$account->addLog('Files imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Import databases
|
// Step 4: Import databases
|
||||||
if (($options['databases'] ?? true) && !empty($account->databases)) {
|
if (($options['databases'] ?? true) && ! empty($account->databases)) {
|
||||||
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
|
$account->update(['current_task' => 'Importing databases...', 'progress' => 60]);
|
||||||
$this->importDatabases($import, $account, $user);
|
$this->importDatabases($import, $account, $user);
|
||||||
$account->addLog("Databases imported");
|
$account->addLog('Databases imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Import emails
|
// Step 5: Import emails
|
||||||
if (($options['emails'] ?? true) && !empty($account->email_accounts)) {
|
if (($options['emails'] ?? true) && ! empty($account->email_accounts)) {
|
||||||
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
|
$account->update(['current_task' => 'Importing email accounts...', 'progress' => 80]);
|
||||||
$this->importEmails($import, $account, $user);
|
$this->importEmails($import, $account, $user);
|
||||||
$account->addLog("Email accounts imported");
|
$account->addLog('Email accounts imported');
|
||||||
}
|
}
|
||||||
|
|
||||||
$account->update([
|
$account->update([
|
||||||
@@ -161,7 +165,7 @@ class ImportProcessCommand extends Command
|
|||||||
'progress' => 100,
|
'progress' => 100,
|
||||||
'current_task' => null,
|
'current_task' => null,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Import completed successfully");
|
$account->addLog('Import completed successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function createUser(ServerImportAccount $account): User
|
private function createUser(ServerImportAccount $account): User
|
||||||
@@ -170,6 +174,7 @@ class ImportProcessCommand extends Command
|
|||||||
$existingUser = User::where('username', $account->target_username)->first();
|
$existingUser = User::where('username', $account->target_username)->first();
|
||||||
if ($existingUser) {
|
if ($existingUser) {
|
||||||
$account->addLog("User already exists: {$account->target_username}");
|
$account->addLog("User already exists: {$account->target_username}");
|
||||||
|
|
||||||
return $existingUser;
|
return $existingUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,8 +184,8 @@ class ImportProcessCommand extends Command
|
|||||||
// Create user via agent
|
// Create user via agent
|
||||||
$result = $this->getAgent()->createUser($account->target_username, $password);
|
$result = $this->getAgent()->createUser($account->target_username, $password);
|
||||||
|
|
||||||
if (!($result['success'] ?? false)) {
|
if (! ($result['success'] ?? false)) {
|
||||||
throw new Exception("Failed to create system user: " . ($result['error'] ?? 'Unknown error'));
|
throw new Exception('Failed to create system user: '.($result['error'] ?? 'Unknown error'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user in database
|
// Create user in database
|
||||||
@@ -191,7 +196,7 @@ class ImportProcessCommand extends Command
|
|||||||
'password' => Hash::make($password),
|
'password' => Hash::make($password),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$account->addLog("Created user with temporary password. User should reset password.");
|
$account->addLog('Created user with temporary password. User should reset password.');
|
||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
@@ -201,19 +206,19 @@ class ImportProcessCommand extends Command
|
|||||||
// Create main domain
|
// Create main domain
|
||||||
if ($account->main_domain) {
|
if ($account->main_domain) {
|
||||||
$existingDomain = Domain::where('domain', $account->main_domain)->first();
|
$existingDomain = Domain::where('domain', $account->main_domain)->first();
|
||||||
if (!$existingDomain) {
|
if (! $existingDomain) {
|
||||||
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
|
$result = $this->getAgent()->domainCreate($user->username, $account->main_domain);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
Domain::create([
|
Domain::create([
|
||||||
'domain' => $account->main_domain,
|
'domain' => $account->main_domain,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public",
|
'document_root' => "/home/{$user->username}/domains/{$account->main_domain}/public_html",
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Created main domain: {$account->main_domain}");
|
$account->addLog("Created main domain: {$account->main_domain}");
|
||||||
} else {
|
} else {
|
||||||
$account->addLog("Warning: Failed to create main domain: " . ($result['error'] ?? 'Unknown'));
|
$account->addLog('Warning: Failed to create main domain: '.($result['error'] ?? 'Unknown'));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$account->addLog("Main domain already exists: {$account->main_domain}");
|
$account->addLog("Main domain already exists: {$account->main_domain}");
|
||||||
@@ -223,14 +228,14 @@ class ImportProcessCommand extends Command
|
|||||||
// Create addon domains
|
// Create addon domains
|
||||||
foreach ($account->addon_domains ?? [] as $domain) {
|
foreach ($account->addon_domains ?? [] as $domain) {
|
||||||
$existingDomain = Domain::where('domain', $domain)->first();
|
$existingDomain = Domain::where('domain', $domain)->first();
|
||||||
if (!$existingDomain) {
|
if (! $existingDomain) {
|
||||||
$result = $this->getAgent()->domainCreate($user->username, $domain);
|
$result = $this->getAgent()->domainCreate($user->username, $domain);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
Domain::create([
|
Domain::create([
|
||||||
'domain' => $domain,
|
'domain' => $domain,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'document_root' => "/home/{$user->username}/domains/{$domain}/public",
|
'document_root' => "/home/{$user->username}/domains/{$domain}/public_html",
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
]);
|
]);
|
||||||
$account->addLog("Created addon domain: {$domain}");
|
$account->addLog("Created addon domain: {$domain}");
|
||||||
@@ -243,31 +248,38 @@ class ImportProcessCommand extends Command
|
|||||||
|
|
||||||
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
|
private function importFiles(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||||
{
|
{
|
||||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||||
$account->addLog("File import skipped - not a backup file import");
|
$account->addLog('File import skipped - not a backup file import');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
$backupPath = $this->resolveBackupFullPath($import);
|
||||||
if (!file_exists($backupPath)) {
|
if (! $backupPath) {
|
||||||
$account->addLog("Warning: Backup file not found");
|
$account->addLog('Warning: Backup file not found');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$extractDir = "/tmp/import_{$import->id}_{$account->id}_" . time();
|
$extractDir = "/tmp/import_{$import->id}_{$account->id}_".time();
|
||||||
if (!mkdir($extractDir, 0755, true)) {
|
if (! mkdir($extractDir, 0755, true)) {
|
||||||
$account->addLog("Warning: Failed to create extraction directory");
|
$account->addLog('Warning: Failed to create extraction directory');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$username = $account->source_username;
|
$username = $account->source_username;
|
||||||
|
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||||
|
|
||||||
if ($import->source_type === 'cpanel') {
|
if ($import->source_type === 'cpanel') {
|
||||||
// Extract home directory from cPanel backup
|
// Extract home directory from cPanel backup
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
|
" --wildcards '*/{$username}/homedir/*' '*/homedir/*' 2>/dev/null";
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find extracted files
|
// Find extracted files
|
||||||
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
|
$homeDirs = glob("$extractDir/**/homedir", GLOB_ONLYDIR) ?:
|
||||||
@@ -278,19 +290,22 @@ class ImportProcessCommand extends Command
|
|||||||
// Copy public_html to the domain
|
// Copy public_html to the domain
|
||||||
$publicHtml = "$homeDir/public_html";
|
$publicHtml = "$homeDir/public_html";
|
||||||
if (is_dir($publicHtml) && $account->main_domain) {
|
if (is_dir($publicHtml) && $account->main_domain) {
|
||||||
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public";
|
$destDir = "/home/{$user->username}/domains/{$account->main_domain}/public_html";
|
||||||
if (is_dir($destDir)) {
|
if (is_dir($destDir)) {
|
||||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
|
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||||
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
|
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||||
$account->addLog("Copied public_html to {$account->main_domain}");
|
$account->addLog("Copied public_html to {$account->main_domain}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Extract from DirectAdmin backup
|
// Extract from DirectAdmin backup
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
|
" --wildcards 'domains/*' 'backup/domains/*' 2>/dev/null";
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract DirectAdmin backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find domain directories
|
// Find domain directories
|
||||||
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
|
$domainDirs = glob("$extractDir/**/domains/*", GLOB_ONLYDIR) ?:
|
||||||
@@ -301,10 +316,10 @@ class ImportProcessCommand extends Command
|
|||||||
$publicHtml = "$domainDir/public_html";
|
$publicHtml = "$domainDir/public_html";
|
||||||
|
|
||||||
if (is_dir($publicHtml)) {
|
if (is_dir($publicHtml)) {
|
||||||
$destDir = "/home/{$user->username}/domains/{$domain}/public";
|
$destDir = "/home/{$user->username}/domains/{$domain}/public_html";
|
||||||
if (is_dir($destDir)) {
|
if (is_dir($destDir)) {
|
||||||
exec("cp -r " . escapeshellarg($publicHtml) . "/* " . escapeshellarg($destDir) . "/ 2>&1");
|
exec('cp -r '.escapeshellarg($publicHtml).'/* '.escapeshellarg($destDir).'/ 2>&1');
|
||||||
exec("chown -R " . escapeshellarg($user->username) . ":" . escapeshellarg($user->username) . " " . escapeshellarg($destDir) . " 2>&1");
|
exec('chown -R '.escapeshellarg($user->username).':'.escapeshellarg($user->username).' '.escapeshellarg($destDir).' 2>&1');
|
||||||
$account->addLog("Copied files for domain: {$domain}");
|
$account->addLog("Copied files for domain: {$domain}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -312,67 +327,115 @@ class ImportProcessCommand extends Command
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
exec("rm -rf " . escapeshellarg($extractDir));
|
exec('rm -rf '.escapeshellarg($extractDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
|
private function importDatabases(ServerImport $import, ServerImportAccount $account, User $user): void
|
||||||
{
|
{
|
||||||
if ($import->import_method !== 'backup_file' || !$import->backup_path) {
|
if ($import->import_method !== 'backup_file' || ! $import->backup_path) {
|
||||||
$account->addLog("Database import skipped - not a backup file import");
|
$account->addLog('Database import skipped - not a backup file import');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$backupPath = Storage::disk('local')->path($import->backup_path);
|
$backupPath = $this->resolveBackupFullPath($import);
|
||||||
if (!file_exists($backupPath)) {
|
if (! $backupPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_" . time();
|
$extractDir = "/tmp/import_db_{$import->id}_{$account->id}_".time();
|
||||||
if (!mkdir($extractDir, 0755, true)) {
|
if (! mkdir($extractDir, 0755, true)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$tarExtract = $this->getTarExtractCommandPrefix($backupPath);
|
||||||
|
|
||||||
// Extract MySQL dumps
|
// Extract MySQL dumps
|
||||||
if ($import->source_type === 'cpanel') {
|
if ($import->source_type === 'cpanel') {
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards '*/mysql/*.sql' 'mysql/*.sql' 2>/dev/null";
|
" --wildcards '*/mysql/*.sql*' 'mysql/*.sql*' 2>/dev/null";
|
||||||
} else {
|
} else {
|
||||||
$cmd = "tar -xzf " . escapeshellarg($backupPath) . " -C " . escapeshellarg($extractDir) .
|
$cmd = "{$tarExtract} ".escapeshellarg($backupPath).' -C '.escapeshellarg($extractDir).
|
||||||
" --wildcards 'backup/databases/*.sql' 'databases/*.sql' 2>/dev/null";
|
" --wildcards 'backup/databases/*.sql*' 'databases/*.sql*' 2>/dev/null";
|
||||||
}
|
}
|
||||||
exec($cmd, $output, $code);
|
exec($cmd, $output, $code);
|
||||||
|
if ($code !== 0) {
|
||||||
|
$account->addLog('Warning: Failed to extract database dumps from backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
// Find SQL files
|
// Find SQL files
|
||||||
$sqlFiles = [];
|
$sqlFiles = [];
|
||||||
exec("find " . escapeshellarg($extractDir) . " -name '*.sql' -type f 2>/dev/null", $sqlFiles);
|
exec('find '.escapeshellarg($extractDir)." -type f \\( -name '*.sql' -o -name '*.sql.gz' -o -name '*.sql.zst' \\) 2>/dev/null", $sqlFiles);
|
||||||
|
|
||||||
foreach ($sqlFiles as $sqlFile) {
|
foreach ($sqlFiles as $sqlFile) {
|
||||||
$dbName = basename($sqlFile, '.sql');
|
$fileName = basename($sqlFile);
|
||||||
|
$dbName = preg_replace('/\\.(sql|sql\\.gz|sql\\.zst)$/i', '', $fileName);
|
||||||
|
|
||||||
|
if (! is_string($dbName) || $dbName === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Create database name with user prefix
|
// Create database name with user prefix
|
||||||
$newDbName = substr($user->username . '_' . preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
|
$newDbName = substr($user->username.'_'.preg_replace('/^[^_]+_/', '', $dbName), 0, 64);
|
||||||
|
|
||||||
// Create database via agent
|
// Create database via agent
|
||||||
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
|
$result = $this->getAgent()->mysqlCreateDatabase($user->username, $newDbName);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
// Import data
|
$sqlToImport = $sqlFile;
|
||||||
$cmd = "mysql " . escapeshellarg($newDbName) . " < " . escapeshellarg($sqlFile) . " 2>&1";
|
$tmpSql = null;
|
||||||
exec($cmd, $importOutput, $importCode);
|
|
||||||
|
|
||||||
if ($importCode === 0) {
|
try {
|
||||||
$account->addLog("Imported database: {$newDbName}");
|
$lower = strtolower($sqlFile);
|
||||||
} else {
|
|
||||||
$account->addLog("Warning: Database created but import failed: {$newDbName}");
|
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 {
|
} else {
|
||||||
$account->addLog("Warning: Failed to create database: {$newDbName}");
|
$account->addLog("Warning: Failed to create database: {$newDbName}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
exec("rm -rf " . escapeshellarg($extractDir));
|
exec('rm -rf '.escapeshellarg($extractDir));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,6 +447,45 @@ class ImportProcessCommand extends Command
|
|||||||
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
|
$account->addLog("Email account found (not imported): {$emailAccount}@{$account->main_domain}");
|
||||||
}
|
}
|
||||||
|
|
||||||
$account->addLog("Note: Email accounts must be recreated manually");
|
$account->addLog('Note: Email accounts must be recreated manually');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBackupFullPath(ServerImport $import): ?string
|
||||||
|
{
|
||||||
|
$path = trim((string) ($import->backup_path ?? ''));
|
||||||
|
if ($path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($path, '/') && file_exists($path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
$localCandidate = Storage::disk('local')->path($path);
|
||||||
|
if (file_exists($localCandidate)) {
|
||||||
|
return $localCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$backupCandidate = Storage::disk('backups')->path($path);
|
||||||
|
if (file_exists($backupCandidate)) {
|
||||||
|
return $backupCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return file_exists($path) ? $path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTarExtractCommandPrefix(string $archivePath): string
|
||||||
|
{
|
||||||
|
$archivePath = strtolower($archivePath);
|
||||||
|
|
||||||
|
if (str_ends_with($archivePath, '.tar.zst') || str_ends_with($archivePath, '.zst')) {
|
||||||
|
return 'tar --zstd -xf';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_ends_with($archivePath, '.tar.gz') || str_ends_with($archivePath, '.tgz')) {
|
||||||
|
return 'tar -xzf';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'tar -xf';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ use Filament\Actions\Contracts\HasActions;
|
|||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Concerns\InteractsWithForms;
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
use Filament\Forms\Contracts\HasForms;
|
use Filament\Forms\Contracts\HasForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\EmbeddedTable;
|
use Filament\Schemas\Components\EmbeddedTable;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Text;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
@@ -50,6 +53,13 @@ class Dashboard extends Page implements HasActions, HasForms
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! DnsSetting::get('onboarding_completed', false)) {
|
||||||
|
$this->defaultAction = 'onboarding';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected function getForms(): array
|
protected function getForms(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -78,27 +88,86 @@ class Dashboard extends Page implements HasActions, HasForms
|
|||||||
->color('gray')
|
->color('gray')
|
||||||
->action(fn () => $this->redirect(request()->url())),
|
->action(fn () => $this->redirect(request()->url())),
|
||||||
|
|
||||||
Action::make('onboarding')
|
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
|
||||||
->label(__('Setup Wizard'))
|
->label(__('Setup Wizard'))
|
||||||
->icon('heroicon-o-sparkles')
|
->icon('heroicon-o-sparkles')
|
||||||
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
|
|
||||||
->modalHeading(__('Welcome to Jabali!'))
|
->modalHeading(__('Welcome to Jabali!'))
|
||||||
->modalDescription(__('Let\'s get your server control panel set up.'))
|
->modalDescription(__('Let\'s get your server control panel set up.'))
|
||||||
->modalWidth('md')
|
->modalWidth('2xl')
|
||||||
|
->fillForm(function (): array {
|
||||||
|
$savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', ''));
|
||||||
|
$savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'admin_email' => $savedPrimaryEmail,
|
||||||
|
];
|
||||||
|
})
|
||||||
->form([
|
->form([
|
||||||
|
Section::make(__('Next Steps'))
|
||||||
|
->description(__('Here is a quick setup path to get your first site online.'))
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact()
|
||||||
|
->schema([
|
||||||
|
Grid::make(['default' => 1, 'md' => 2])
|
||||||
|
->schema([
|
||||||
|
Section::make(__('1. Configure Server Settings'))
|
||||||
|
->description(__('Set hostname, DNS, email, storage, and PHP defaults.'))
|
||||||
|
->icon('heroicon-o-cog-6-tooth')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('2. Create a Hosting Package'))
|
||||||
|
->description(__('Define limits and features for your plans.'))
|
||||||
|
->icon('heroicon-o-cube')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('3. Create a User'))
|
||||||
|
->description(__('Assign the hosting package and set credentials.'))
|
||||||
|
->icon('heroicon-o-user-plus')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('4. Add a Domain'))
|
||||||
|
->description(__('Issue SSL and deploy your site files.'))
|
||||||
|
->icon('heroicon-o-globe-alt')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
]),
|
||||||
|
Text::make(__('Optional: review Services and Server Status to confirm everything is healthy.'))
|
||||||
|
->color('gray'),
|
||||||
|
]),
|
||||||
TextInput::make('admin_email')
|
TextInput::make('admin_email')
|
||||||
->label(__('Your Email Address'))
|
->label(__('Your Email Address'))
|
||||||
->helperText(__('Enter your email to receive important server notifications.'))
|
->helperText(__('Enter your email to receive important server notifications.'))
|
||||||
->email()
|
->email()
|
||||||
->placeholder(__('admin@example.com')),
|
->placeholder(__('admin@example.com')),
|
||||||
])
|
])
|
||||||
->modalSubmitActionLabel(__('Get Started'))
|
->modalSubmitActionLabel(__('Save and close'))
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
if (! empty($data['admin_email'])) {
|
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
|
||||||
DnsSetting::set('admin_email_recipients', $data['admin_email']);
|
|
||||||
|
if ($adminEmail !== '') {
|
||||||
|
DnsSetting::set('admin_email_recipients', $adminEmail);
|
||||||
}
|
}
|
||||||
|
|
||||||
DnsSetting::set('onboarding_completed', '1');
|
DnsSetting::set('onboarding_completed', '1');
|
||||||
DnsSetting::clearCache();
|
DnsSetting::clearCache();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title(__('Setup saved'))
|
||||||
|
->body(__('Your notification email has been updated.'))
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
779
app/Filament/Admin/Pages/DirectAdminMigration.php
Normal file
779
app/Filament/Admin/Pages/DirectAdminMigration.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,19 +41,19 @@ class Migration extends Page implements HasForms
|
|||||||
|
|
||||||
public function getSubheading(): ?string
|
public function getSubheading(): ?string
|
||||||
{
|
{
|
||||||
return __('Migrate cPanel accounts directly or via WHM');
|
return __('Migrate cPanel, WHM, or DirectAdmin accounts into Jabali');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mount(): void
|
public function mount(): void
|
||||||
{
|
{
|
||||||
if (! in_array($this->activeTab, ['cpanel', 'whm'], true)) {
|
if (! in_array($this->activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
|
||||||
$this->activeTab = 'cpanel';
|
$this->activeTab = 'cpanel';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedActiveTab(string $activeTab): void
|
public function updatedActiveTab(string $activeTab): void
|
||||||
{
|
{
|
||||||
if (! in_array($activeTab, ['cpanel', 'whm'], true)) {
|
if (! in_array($activeTab, ['cpanel', 'whm', 'directadmin'], true)) {
|
||||||
$this->activeTab = 'cpanel';
|
$this->activeTab = 'cpanel';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,6 +79,11 @@ class Migration extends Page implements HasForms
|
|||||||
->schema([
|
->schema([
|
||||||
View::make('filament.admin.pages.migration-whm-tab'),
|
View::make('filament.admin.pages.migration-whm-tab'),
|
||||||
]),
|
]),
|
||||||
|
'directadmin' => Tabs\Tab::make(__('DirectAdmin Migration'))
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->schema([
|
||||||
|
View::make('filament.admin.pages.migration-directadmin-tab'),
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn () => $this->fail2banRunning)
|
->visible(fn () => $this->fail2banRunning)
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Disable Fail2ban'))
|
->modalHeading(__('Disable Fail2ban'))
|
||||||
->modalDescription(__('Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalIconColor('warning')
|
||||||
|
->modalDescription(__('Warning: Fail2ban will be stopped and disabled. You can re-enable it later from this tab.'))
|
||||||
->action('disableFail2ban'),
|
->action('disableFail2ban'),
|
||||||
])
|
])
|
||||||
->schema([
|
->schema([
|
||||||
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
|
|||||||
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
|
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
|
||||||
->size('sm')
|
->size('sm')
|
||||||
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
|
->action(fn () => $this->clamavRunning ? $this->disableClamav() : $this->enableClamav())
|
||||||
->requiresConfirmation(fn () => ! $this->clamavRunning)
|
->requiresConfirmation()
|
||||||
|
->modalHeading(fn () => $this->clamavRunning ? __('Disable ClamAV') : __('Enable ClamAV'))
|
||||||
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalIconColor('warning')
|
||||||
->modalDescription(fn () => $this->clamavRunning
|
->modalDescription(fn () => $this->clamavRunning
|
||||||
? __('ClamAV will be stopped and disabled. You can re-enable it later.')
|
? __('Warning: This will stop and disable ClamAV. You can re-enable it later.')
|
||||||
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
|
: __('Starting ClamAV daemon uses ~500MB RAM. Continue?')),
|
||||||
FormAction::make('updateSignatures')
|
FormAction::make('updateSignatures')
|
||||||
->label(__('Update Signatures'))
|
->label(__('Update Signatures'))
|
||||||
|
|||||||
@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
|
|||||||
protected function databaseTabContent(): array
|
protected function databaseTabContent(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
Section::make(__('Warning: Changing database settings can impact performance or cause outages'))
|
||||||
|
->description(__('Apply changes only if you understand their effects, and prefer doing so during maintenance windows.'))
|
||||||
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
|
->iconColor('warning')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
Section::make(__('Database Tuning'))
|
Section::make(__('Database Tuning'))
|
||||||
->description(__('Adjust MariaDB/MySQL global variables.'))
|
->description(__('Adjust MariaDB/MySQL global variables.'))
|
||||||
->icon('heroicon-o-circle-stack')
|
->icon('heroicon-o-circle-stack')
|
||||||
->schema([
|
->schema([
|
||||||
Placeholder::make('database_tuning_warning')
|
|
||||||
->content(new HtmlString(
|
|
||||||
'<div class="rounded-lg bg-warning-500/10 p-4 text-sm text-warning-700 dark:text-warning-400">'.
|
|
||||||
'<strong>'.__('Warning:').'</strong> '.
|
|
||||||
__('Changing database settings can impact performance or cause outages. Apply changes only if you understand their effects, and prefer doing so during maintenance windows.').
|
|
||||||
'</div>'
|
|
||||||
)),
|
|
||||||
EmbeddedTable::make(DatabaseTuningTable::class),
|
EmbeddedTable::make(DatabaseTuningTable::class),
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn (array $record): bool => $record['is_active'])
|
->visible(fn (array $record): bool => $record['is_active'])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Stop Service'))
|
->modalHeading(__('Stop Service'))
|
||||||
->modalDescription(fn (array $record): string => __('Are you sure you want to stop :service? This may affect running websites and services.', ['service' => $record['name']]))
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalIconColor('warning')
|
||||||
|
->modalDescription(fn (array $record): string => __('Warning: This will stop :service and may affect running websites and services. Are you sure you want to continue?', ['service' => $record['name']]))
|
||||||
->modalSubmitActionLabel(__('Stop Service'))
|
->modalSubmitActionLabel(__('Stop Service'))
|
||||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
|
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
|
||||||
Action::make('restart')
|
Action::make('restart')
|
||||||
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
|
|||||||
->visible(fn (array $record): bool => $record['is_enabled'])
|
->visible(fn (array $record): bool => $record['is_enabled'])
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(__('Disable Service'))
|
->modalHeading(__('Disable Service'))
|
||||||
->modalDescription(fn (array $record): string => __("Are you sure you want to disable :service? It won't start automatically on boot.", ['service' => $record['name']]))
|
->modalIcon('heroicon-o-exclamation-triangle')
|
||||||
|
->modalIconColor('warning')
|
||||||
|
->modalDescription(fn (array $record): string => __('Warning: This will disable :service and it will not start automatically on boot. Are you sure you want to continue?', ['service' => $record['name']]))
|
||||||
->modalSubmitActionLabel(__('Disable Service'))
|
->modalSubmitActionLabel(__('Disable Service'))
|
||||||
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
|
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
|
||||||
])
|
])
|
||||||
|
|||||||
30
app/Filament/Admin/Pages/Support.php
Normal file
30
app/Filament/Admin/Pages/Support.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Admin\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class Support extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 23;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'support';
|
||||||
|
|
||||||
|
protected string $view = 'filament.admin.pages.support';
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Support');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Support');
|
||||||
|
}
|
||||||
|
}
|
||||||
142
app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php
Normal file
142
app/Filament/Admin/Widgets/DirectAdminAccountConfigTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
155
app/Filament/Admin/Widgets/DirectAdminAccountsTable.php
Normal file
155
app/Filament/Admin/Widgets/DirectAdminAccountsTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
186
app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php
Normal file
186
app/Filament/Admin/Widgets/DirectAdminMigrationStatusTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = null;
|
protected static ?string $navigationLabel = null;
|
||||||
|
|
||||||
|
protected static bool $shouldRegisterNavigation = false;
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('cPanel Migration');
|
return __('cPanel Migration');
|
||||||
|
|||||||
@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
|
|||||||
use App\Filament\Jabali\Widgets\MailboxesWidget;
|
use App\Filament\Jabali\Widgets\MailboxesWidget;
|
||||||
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
|
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
|
||||||
use App\Filament\Jabali\Widgets\StatsOverview;
|
use App\Filament\Jabali\Widgets\StatsOverview;
|
||||||
|
use App\Models\DnsSetting;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Filament\Pages\Dashboard as BaseDashboard;
|
use Filament\Pages\Dashboard as BaseDashboard;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Text;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class Dashboard extends BaseDashboard
|
class Dashboard extends BaseDashboard
|
||||||
@@ -41,6 +46,75 @@ class Dashboard extends BaseDashboard
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
if (! DnsSetting::get('user_onboarding_completed_'.(string) Auth::id(), false)) {
|
||||||
|
$this->defaultAction = 'onboarding';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
|
||||||
|
->label(__('Setup Wizard'))
|
||||||
|
->icon('heroicon-o-sparkles')
|
||||||
|
|
||||||
|
->modalHeading(__('Welcome to Jabali!'))
|
||||||
|
->modalDescription(__('Here is a quick path to launch your first site.'))
|
||||||
|
->modalWidth('2xl')
|
||||||
|
->form([
|
||||||
|
Section::make(__('Next Steps'))
|
||||||
|
->description(__('Follow these steps to get online quickly.'))
|
||||||
|
->icon('heroicon-o-check-circle')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact()
|
||||||
|
->schema([
|
||||||
|
Grid::make(['default' => 1, 'md' => 2])
|
||||||
|
->schema([
|
||||||
|
Section::make(__('1. Add a Domain'))
|
||||||
|
->description(__('Point your DNS to this server or update nameservers.'))
|
||||||
|
->icon('heroicon-o-globe-alt')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('2. Issue SSL'))
|
||||||
|
->description(__('Enable HTTPS for your site with SSL certificates.'))
|
||||||
|
->icon('heroicon-o-lock-closed')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('3. Upload or Install'))
|
||||||
|
->description(__('Upload files or install WordPress to deploy your site.'))
|
||||||
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
Section::make(__('4. Create Email & Databases'))
|
||||||
|
->description(__('Set up mailboxes and databases for your app.'))
|
||||||
|
->icon('heroicon-o-envelope')
|
||||||
|
->iconColor('info')
|
||||||
|
->collapsed(false)
|
||||||
|
->collapsible(false)
|
||||||
|
->compact(),
|
||||||
|
]),
|
||||||
|
Text::make(__('Optional: configure backups, cron jobs, and SSH keys for day-to-day operations.'))
|
||||||
|
->color('gray'),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->modalSubmitActionLabel(__("Don't show again"))
|
||||||
|
->action(function (): void {
|
||||||
|
DnsSetting::set('user_onboarding_completed_'.(string) Auth::id(), '1');
|
||||||
|
DnsSetting::clearCache();
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function getSubheading(): ?string
|
public function getSubheading(): ?string
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
955
app/Filament/Jabali/Pages/DirectAdminMigration.php
Normal file
955
app/Filament/Jabali/Pages/DirectAdminMigration.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ use Filament\Pages\Page;
|
|||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Schemas\Components\View;
|
use Filament\Schemas\Components\View;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Domain'))
|
->label(__('Domain'))
|
||||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->live()
|
||||||
|
->live(),
|
||||||
TextInput::make('local_part')
|
TextInput::make('local_part')
|
||||||
->label(__('Email Address'))
|
->label(__('Email Address'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
->helperText(__('The part before the @ symbol')),
|
->helperText(__('The part before the @ symbol')),
|
||||||
TextInput::make('name')
|
TextInput::make('name')
|
||||||
->label(__('Display Name'))
|
->label(__('Display Name'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('password')
|
TextInput::make('password')
|
||||||
->label(__('Password'))
|
->label(__('Password'))
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->minLength(8)
|
->minLength(8)
|
||||||
->rules([
|
->rules([
|
||||||
'regex:/[a-z]/', // lowercase
|
'regex:/[a-z]/', // lowercase
|
||||||
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
TextInput::make('quota_mb')
|
TextInput::make('quota_mb')
|
||||||
->label(__('Quota (MB)'))
|
->label(__('Quota (MB)'))
|
||||||
->numeric()
|
->numeric()
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->default(1024)
|
->default(1024)
|
||||||
->minValue(100)
|
->minValue(100)
|
||||||
->maxValue(10240)
|
->maxValue(10240)
|
||||||
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Domain'))
|
->label(__('Domain'))
|
||||||
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
|
||||||
->required()
|
->required()
|
||||||
->searchable(),
|
->searchable()
|
||||||
|
->live(),
|
||||||
TextInput::make('local_part')
|
TextInput::make('local_part')
|
||||||
->label(__('Email Address'))
|
->label(__('Email Address'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
->regex('/^[a-zA-Z0-9._%+-]+$/')
|
||||||
->maxLength(64)
|
->maxLength(64)
|
||||||
->helperText(__('The part before the @ symbol')),
|
->helperText(__('The part before the @ symbol')),
|
||||||
TextInput::make('destinations')
|
TextInput::make('destinations')
|
||||||
->label(__('Forward To'))
|
->label(__('Forward To'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain_id')))
|
||||||
->helperText(__('Comma-separated email addresses to forward to')),
|
->helperText(__('Comma-separated email addresses to forward to')),
|
||||||
])
|
])
|
||||||
->action(function (array $data): void {
|
->action(function (array $data): void {
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
public function getTitle(): string|Htmlable
|
public function getTitle(): string|Htmlable
|
||||||
{
|
{
|
||||||
return 'File Manager';
|
return __('File Manager');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getAgent(): AgentClient
|
public function getAgent(): AgentClient
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
protected function getWebhookUrl(GitDeploymentModel $deployment): string
|
protected function getWebhookUrl(GitDeploymentModel $deployment): string
|
||||||
{
|
{
|
||||||
return url("/api/webhooks/git/{$deployment->id}/{$deployment->secret_token}");
|
return url("/api/webhooks/git/{$deployment->id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getDeployKey(): string
|
protected function getDeployKey(): string
|
||||||
@@ -162,6 +162,11 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
->rows(2)
|
->rows(2)
|
||||||
->disabled()
|
->disabled()
|
||||||
->dehydrated(false),
|
->dehydrated(false),
|
||||||
|
TextInput::make('webhook_secret')
|
||||||
|
->label(__('Webhook Secret'))
|
||||||
|
->helperText(__('Set this as your provider webhook secret. Jabali validates HMAC-SHA256 signatures.'))
|
||||||
|
->disabled()
|
||||||
|
->dehydrated(false),
|
||||||
Textarea::make('deploy_key')
|
Textarea::make('deploy_key')
|
||||||
->label(__('Deploy Key'))
|
->label(__('Deploy Key'))
|
||||||
->rows(3)
|
->rows(3)
|
||||||
@@ -170,6 +175,7 @@ class GitDeployment extends Page implements HasActions, HasForms, HasTable
|
|||||||
])
|
])
|
||||||
->fillForm(fn (GitDeploymentModel $record): array => [
|
->fillForm(fn (GitDeploymentModel $record): array => [
|
||||||
'webhook_url' => $this->getWebhookUrl($record),
|
'webhook_url' => $this->getWebhookUrl($record),
|
||||||
|
'webhook_secret' => $record->secret_token,
|
||||||
'deploy_key' => $this->getDeployKey(),
|
'deploy_key' => $this->getDeployKey(),
|
||||||
]),
|
]),
|
||||||
Action::make('edit')
|
Action::make('edit')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Filament\Jabali\Pages;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
use App\Filament\Jabali\Widgets\ActivityLogTable;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@@ -16,6 +16,7 @@ use Filament\Notifications\Notification;
|
|||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Tabs;
|
use Filament\Schemas\Components\Tabs;
|
||||||
use Filament\Schemas\Components\Tabs\Tab;
|
use Filament\Schemas\Components\Tabs\Tab;
|
||||||
|
use Filament\Schemas\Components\EmbeddedTable;
|
||||||
use Filament\Schemas\Components\View;
|
use Filament\Schemas\Components\View;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Contracts\Support\Htmlable;
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
@@ -119,7 +120,7 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
'activity' => Tab::make(__('Activity Log'))
|
'activity' => Tab::make(__('Activity Log'))
|
||||||
->icon('heroicon-o-clipboard-document-list')
|
->icon('heroicon-o-clipboard-document-list')
|
||||||
->schema([
|
->schema([
|
||||||
View::make('filament.jabali.pages.logs-tab-activity'),
|
EmbeddedTable::make(ActivityLogTable::class),
|
||||||
]),
|
]),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -227,15 +228,6 @@ class Logs extends Page implements HasActions, HasForms
|
|||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getActivityLogs()
|
|
||||||
{
|
|
||||||
return AuditLog::query()
|
|
||||||
->where('user_id', Auth::id())
|
|
||||||
->latest()
|
|
||||||
->limit(50)
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateStats(): void
|
public function generateStats(): void
|
||||||
{
|
{
|
||||||
if (! $this->selectedDomain) {
|
if (! $this->selectedDomain) {
|
||||||
|
|||||||
85
app/Filament/Jabali/Pages/Migration.php
Normal file
85
app/Filament/Jabali/Pages/Migration.php
Normal 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'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Filament/Jabali/Pages/Support.php
Normal file
30
app/Filament/Jabali/Pages/Support.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Contracts\Support\Htmlable;
|
||||||
|
|
||||||
|
class Support extends Page
|
||||||
|
{
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-question-mark-circle';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 23;
|
||||||
|
|
||||||
|
protected static ?string $slug = 'support';
|
||||||
|
|
||||||
|
protected string $view = 'filament.jabali.pages.support';
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('Support');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string|Htmlable
|
||||||
|
{
|
||||||
|
return __('Support');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
|||||||
namespace App\Filament\Jabali\Pages;
|
namespace App\Filament\Jabali\Pages;
|
||||||
|
|
||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
|
use App\Models\DnsRecord;
|
||||||
|
use App\Models\DnsSetting;
|
||||||
use App\Models\MysqlCredential;
|
use App\Models\MysqlCredential;
|
||||||
use App\Services\Agent\AgentClient;
|
use App\Services\Agent\AgentClient;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -22,6 +24,7 @@ use Filament\Infolists\Components\TextEntry;
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Components\Utilities\Get;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Columns\ViewColumn;
|
use Filament\Tables\Columns\ViewColumn;
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
@@ -204,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->modalDescription(__('This will create a copy of your site for testing.'))
|
->modalDescription(__('This will create a copy of your site for testing.'))
|
||||||
->modalIcon('heroicon-o-document-duplicate')
|
->modalIcon('heroicon-o-document-duplicate')
|
||||||
->modalIconColor('info')
|
->modalIconColor('info')
|
||||||
->form([
|
->form(function (array $record): array {
|
||||||
TextInput::make('staging_subdomain')
|
$sourceDomain = strtolower(trim((string) ($record['domain'] ?? '')));
|
||||||
->label(__('Staging Subdomain'))
|
$ownedDomainOptions = $this->getOwnedDomainOptions([$sourceDomain]);
|
||||||
->prefix('staging-')
|
|
||||||
->suffix(fn (array $record): string => '.'.($record['domain'] ?? ''))
|
return [
|
||||||
->default('test')
|
Select::make('staging_target_type')
|
||||||
->required()
|
->label(__('Target Type'))
|
||||||
->alphaNum(),
|
->options([
|
||||||
])
|
'subdomain' => __('Subdomain (on source domain)'),
|
||||||
->action(fn (array $data, array $record) => $this->createStaging($record['id'], $data['staging_subdomain'])),
|
'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')
|
Action::make('pushStaging')
|
||||||
->label(__('Push to Production'))
|
->label(__('Push to Production'))
|
||||||
->icon('heroicon-o-arrow-up-tray')
|
->icon('heroicon-o-arrow-up-tray')
|
||||||
@@ -258,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
);
|
);
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
|
if (($record['is_staging'] ?? false)) {
|
||||||
|
$this->removeStagingDnsRecords($record);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($record['is_staging'] ?? false) && ! empty($record['domain'])) {
|
||||||
|
Domain::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->where('domain', (string) $record['domain'])
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
// Delete screenshot if exists
|
// Delete screenshot if exists
|
||||||
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
|
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
|
||||||
if (file_exists($screenshotPath)) {
|
if (file_exists($screenshotPath)) {
|
||||||
@@ -448,24 +492,29 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->options($domainOptions)
|
->options($domainOptions)
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
|
->live()
|
||||||
->placeholder(__('Select a domain...'))
|
->placeholder(__('Select a domain...'))
|
||||||
->helperText(__('The domain where WordPress will be installed')),
|
->helperText(__('The domain where WordPress will be installed')),
|
||||||
Toggle::make('use_www')
|
Toggle::make('use_www')
|
||||||
->label(__('Use www prefix'))
|
->label(__('Use www prefix'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Install on www.domain.com instead of domain.com'))
|
->helperText(__('Install on www.domain.com instead of domain.com'))
|
||||||
->default(false),
|
->default(false),
|
||||||
TextInput::make('path')
|
TextInput::make('path')
|
||||||
->label(__('Directory (optional)'))
|
->label(__('Directory (optional)'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->placeholder(__('Leave empty to install in root'))
|
->placeholder(__('Leave empty to install in root'))
|
||||||
->helperText(__('e.g., "blog" to install at domain.com/blog')),
|
->helperText(__('e.g., "blog" to install at domain.com/blog')),
|
||||||
TextInput::make('site_title')
|
TextInput::make('site_title')
|
||||||
->label(__('Site Title'))
|
->label(__('Site Title'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->default(__('My WordPress Site'))
|
->default(__('My WordPress Site'))
|
||||||
->helperText(__('The name of your WordPress site')),
|
->helperText(__('The name of your WordPress site')),
|
||||||
TextInput::make('admin_user')
|
TextInput::make('admin_user')
|
||||||
->label(__('Admin Username'))
|
->label(__('Admin Username'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->default('admin')
|
->default('admin')
|
||||||
->alphaNum()
|
->alphaNum()
|
||||||
->helperText(__('Username for the WordPress admin account')),
|
->helperText(__('Username for the WordPress admin account')),
|
||||||
@@ -473,7 +522,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->label(__('Admin Password'))
|
->label(__('Admin Password'))
|
||||||
->password()
|
->password()
|
||||||
->revealable()
|
->revealable()
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->default(fn () => $this->generateSecurePassword())
|
->default(fn () => $this->generateSecurePassword())
|
||||||
->minLength(8)
|
->minLength(8)
|
||||||
->rules([
|
->rules([
|
||||||
@@ -504,7 +554,8 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
|
->helperText(__('Minimum 8 characters with uppercase, lowercase, and numbers')),
|
||||||
TextInput::make('admin_email')
|
TextInput::make('admin_email')
|
||||||
->label(__('Admin Email'))
|
->label(__('Admin Email'))
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->email()
|
->email()
|
||||||
->default(Auth::user()->email ?? '')
|
->default(Auth::user()->email ?? '')
|
||||||
->helperText(__('Email address for the WordPress admin account')),
|
->helperText(__('Email address for the WordPress admin account')),
|
||||||
@@ -538,14 +589,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
])
|
])
|
||||||
->default('en_US')
|
->default('en_US')
|
||||||
->searchable()
|
->searchable()
|
||||||
->required()
|
->required(fn (Get $get): bool => filled($get('domain')))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Default language for WordPress admin and content')),
|
->helperText(__('Default language for WordPress admin and content')),
|
||||||
Toggle::make('enable_cache')
|
Toggle::make('enable_cache')
|
||||||
->label(__('Enable Jabali Cache'))
|
->label(__('Enable Jabali Cache'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Install Redis object caching for better performance'))
|
->helperText(__('Install Redis object caching for better performance'))
|
||||||
->default(true),
|
->default(true),
|
||||||
Toggle::make('enable_auto_update')
|
Toggle::make('enable_auto_update')
|
||||||
->label(__('Enable Auto-Updates'))
|
->label(__('Enable Auto-Updates'))
|
||||||
|
->visible(fn (Get $get): bool => filled($get('domain')))
|
||||||
->helperText(__('Automatically update WordPress, plugins, and themes'))
|
->helperText(__('Automatically update WordPress, plugins, and themes'))
|
||||||
->default(false),
|
->default(false),
|
||||||
])
|
])
|
||||||
@@ -883,22 +937,75 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createStaging(string $siteId, string $subdomain): void
|
public function createStaging(string $siteId, string $subdomain, string $targetDomain = '', string $targetType = 'subdomain'): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
$sourceSite = collect($this->sites)->firstWhere('id', $siteId);
|
||||||
|
$sourceDomain = (string) ($sourceSite['domain'] ?? '');
|
||||||
|
$normalizedSourceDomain = strtolower(trim($sourceDomain));
|
||||||
|
|
||||||
|
$agentPayload = [
|
||||||
|
'username' => $this->getUsername(),
|
||||||
|
'site_id' => $siteId,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($targetType === 'domain') {
|
||||||
|
$targetDomain = strtolower(trim($targetDomain));
|
||||||
|
if ($targetDomain === '') {
|
||||||
|
throw new Exception(__('Please choose a target domain.'));
|
||||||
|
}
|
||||||
|
if ($targetDomain === $normalizedSourceDomain) {
|
||||||
|
throw new Exception(__('The staging domain must be different from the source domain.'));
|
||||||
|
}
|
||||||
|
if (! $this->isOwnedDomain($targetDomain)) {
|
||||||
|
throw new Exception(__('The selected domain is not in your domain list.'));
|
||||||
|
}
|
||||||
|
$agentPayload['target_domain'] = $targetDomain;
|
||||||
|
} else {
|
||||||
|
$subdomain = strtolower(trim($subdomain));
|
||||||
|
if ($subdomain === '') {
|
||||||
|
throw new Exception(__('Please enter a subdomain.'));
|
||||||
|
}
|
||||||
|
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $subdomain)) {
|
||||||
|
throw new Exception(__('Subdomain can contain only letters, numbers, and hyphens.'));
|
||||||
|
}
|
||||||
|
$agentPayload['subdomain'] = $subdomain;
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Creating Staging Environment...'))
|
->title(__('Creating Staging Environment...'))
|
||||||
->body(__('This may take several minutes.'))
|
->body(__('This may take several minutes.'))
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$result = $this->getAgent()->send('wp.create_staging', [
|
$result = $this->getAgent()->send('wp.create_staging', $agentPayload);
|
||||||
'username' => $this->getUsername(),
|
|
||||||
'site_id' => $siteId,
|
|
||||||
'subdomain' => 'staging-'.$subdomain,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($result['success'] ?? false) {
|
if ($result['success'] ?? false) {
|
||||||
|
$stagingDomain = (string) ($result['staging_domain'] ?? '');
|
||||||
|
if ($stagingDomain !== '') {
|
||||||
|
Domain::firstOrCreate(
|
||||||
|
[
|
||||||
|
'user_id' => Auth::id(),
|
||||||
|
'domain' => $stagingDomain,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'document_root' => '/home/'.$this->getUsername().'/domains/'.$stagingDomain.'/public_html',
|
||||||
|
'is_active' => true,
|
||||||
|
'ssl_enabled' => false,
|
||||||
|
'directory_index' => 'index.php index.html',
|
||||||
|
'page_cache_enabled' => false,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$sourceDomain !== ''
|
||||||
|
&& $stagingDomain !== ''
|
||||||
|
&& str_ends_with(strtolower($stagingDomain), '.'.strtolower($sourceDomain))
|
||||||
|
) {
|
||||||
|
$this->ensureStagingDnsRecords($sourceDomain, $stagingDomain);
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title(__('Staging Environment Created'))
|
->title(__('Staging Environment Created'))
|
||||||
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
|
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
|
||||||
@@ -1455,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
|
|||||||
|
|
||||||
return file_exists(storage_path('app/public/screenshots/'.$filename));
|
return file_exists(storage_path('app/public/screenshots/'.$filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function ensureStagingDnsRecords(string $sourceDomainName, string $stagingDomainName): void
|
||||||
|
{
|
||||||
|
$sourceDomain = Domain::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->where('domain', $sourceDomainName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $sourceDomain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
|
||||||
|
if ($label === null || $label === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = DnsSetting::getAll();
|
||||||
|
$defaultTtl = (int) ($settings['default_ttl'] ?? 3600);
|
||||||
|
|
||||||
|
$defaultIpv4 = $sourceDomain->ip_address
|
||||||
|
?: ($settings['default_ip'] ?? trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')) ?: '127.0.0.1');
|
||||||
|
|
||||||
|
DnsRecord::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'domain_id' => $sourceDomain->id,
|
||||||
|
'name' => $label,
|
||||||
|
'type' => 'A',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'content' => $defaultIpv4,
|
||||||
|
'ttl' => $defaultTtl,
|
||||||
|
'priority' => null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$defaultIpv6 = $sourceDomain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
|
||||||
|
if (! empty($defaultIpv6)) {
|
||||||
|
DnsRecord::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'domain_id' => $sourceDomain->id,
|
||||||
|
'name' => $label,
|
||||||
|
'type' => 'AAAA',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'content' => $defaultIpv6,
|
||||||
|
'ttl' => $defaultTtl,
|
||||||
|
'priority' => null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->syncDnsZone($sourceDomain, $settings);
|
||||||
|
} catch (Exception) {
|
||||||
|
// Keep staging creation successful even if DNS sync is temporarily unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function removeStagingDnsRecords(array $stagingSite): void
|
||||||
|
{
|
||||||
|
$stagingDomainName = strtolower(trim((string) ($stagingSite['domain'] ?? '')));
|
||||||
|
if ($stagingDomainName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceDomainName = '';
|
||||||
|
$sourceSiteId = $stagingSite['source_site_id'] ?? null;
|
||||||
|
if (is_string($sourceSiteId) && $sourceSiteId !== '') {
|
||||||
|
$sourceSite = collect($this->sites)->firstWhere('id', $sourceSiteId);
|
||||||
|
$sourceDomainName = strtolower(trim((string) ($sourceSite['domain'] ?? '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceDomainName === '') {
|
||||||
|
$parts = explode('.', $stagingDomainName, 2);
|
||||||
|
if (count($parts) === 2) {
|
||||||
|
$sourceDomainName = $parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sourceDomainName === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceDomain = Domain::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->where('domain', $sourceDomainName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $sourceDomain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
|
||||||
|
if ($label === null || $label === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DnsRecord::query()
|
||||||
|
->where('domain_id', $sourceDomain->id)
|
||||||
|
->where('name', $label)
|
||||||
|
->whereIn('type', ['A', 'AAAA'])
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->syncDnsZone($sourceDomain);
|
||||||
|
} catch (Exception) {
|
||||||
|
// Keep deletion successful even if DNS sync is temporarily unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function syncDnsZone(Domain $domain, ?array $settings = null): void
|
||||||
|
{
|
||||||
|
$settings ??= DnsSetting::getAll();
|
||||||
|
$records = DnsRecord::query()->where('domain_id', $domain->id)->get()->toArray();
|
||||||
|
$hostname = gethostname() ?: 'localhost';
|
||||||
|
$serverIp = trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? ''));
|
||||||
|
|
||||||
|
$this->getAgent()->dnsSyncZone($domain->domain, $records, [
|
||||||
|
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
|
||||||
|
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
|
||||||
|
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
|
||||||
|
'default_ip' => $settings['default_ip'] ?? ($serverIp !== '' ? $serverIp : '127.0.0.1'),
|
||||||
|
'default_ipv6' => $settings['default_ipv6'] ?? null,
|
||||||
|
'default_ttl' => $settings['default_ttl'] ?? 3600,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function extractSubdomainLabel(string $fullDomain, string $baseDomain): ?string
|
||||||
|
{
|
||||||
|
$fullDomain = strtolower(trim($fullDomain, " \t\n\r\0\x0B."));
|
||||||
|
$baseDomain = strtolower(trim($baseDomain, " \t\n\r\0\x0B."));
|
||||||
|
|
||||||
|
if ($fullDomain === '' || $baseDomain === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($fullDomain === $baseDomain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$suffix = '.'.$baseDomain;
|
||||||
|
if (! str_ends_with($fullDomain, $suffix)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = substr($fullDomain, 0, -strlen($suffix));
|
||||||
|
|
||||||
|
return trim((string) $label, '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getOwnedDomainOptions(array $exclude = []): array
|
||||||
|
{
|
||||||
|
$excludeSet = [];
|
||||||
|
foreach ($exclude as $value) {
|
||||||
|
$normalized = strtolower(trim((string) $value));
|
||||||
|
if ($normalized !== '') {
|
||||||
|
$excludeSet[$normalized] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
foreach ($this->domains as $domain) {
|
||||||
|
$name = strtolower(trim((string) ($domain['domain'] ?? '')));
|
||||||
|
if ($name === '' || isset($excludeSet[$name])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$options[$name] = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $options;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isOwnedDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
$domain = strtolower(trim($domain));
|
||||||
|
if ($domain === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->domains as $ownedDomain) {
|
||||||
|
$candidate = strtolower(trim((string) ($ownedDomain['domain'] ?? '')));
|
||||||
|
if ($candidate === $domain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
app/Filament/Jabali/Widgets/ActivityLogTable.php
Normal file
79
app/Filament/Jabali/Widgets/ActivityLogTable.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Filament\Jabali\Widgets;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Filament\Actions\Concerns\InteractsWithActions;
|
||||||
|
use Filament\Actions\Contracts\HasActions;
|
||||||
|
use Filament\Schemas\Concerns\InteractsWithSchemas;
|
||||||
|
use Filament\Schemas\Contracts\HasSchemas;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
class ActivityLogTable extends Component implements HasTable, HasSchemas, HasActions
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
use InteractsWithSchemas;
|
||||||
|
use InteractsWithActions;
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->query(
|
||||||
|
AuditLog::query()
|
||||||
|
->where('user_id', Auth::id())
|
||||||
|
->latest()
|
||||||
|
)
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label(__('Time'))
|
||||||
|
->dateTime('M d, H:i')
|
||||||
|
->color('gray'),
|
||||||
|
TextColumn::make('category')
|
||||||
|
->label(__('Category'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'domain' => 'info',
|
||||||
|
'email' => 'primary',
|
||||||
|
'database' => 'warning',
|
||||||
|
'auth' => 'gray',
|
||||||
|
'firewall' => 'danger',
|
||||||
|
'service' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('action')
|
||||||
|
->label(__('Action'))
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state): string => match ($state) {
|
||||||
|
'create', 'created' => 'success',
|
||||||
|
'update', 'updated' => 'warning',
|
||||||
|
'delete', 'deleted' => 'danger',
|
||||||
|
'login' => 'info',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('description')
|
||||||
|
->label(__('Description'))
|
||||||
|
->limit(60)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('ip_address')
|
||||||
|
->label(__('IP'))
|
||||||
|
->color('gray'),
|
||||||
|
])
|
||||||
|
->defaultPaginationPageOption(25)
|
||||||
|
->striped()
|
||||||
|
->emptyStateHeading(__('No activity recorded yet'))
|
||||||
|
->emptyStateDescription(__('Recent actions performed in your account will appear here.'))
|
||||||
|
->emptyStateIcon('heroicon-o-clipboard-document-list');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return $this->getTable()->render();
|
||||||
|
}
|
||||||
|
}
|
||||||
170
app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php
Normal file
170
app/Filament/Jabali/Widgets/DirectAdminMigrationStatusTable.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,9 +11,17 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
class GitWebhookController extends Controller
|
class GitWebhookController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, GitDeployment $deployment, string $token): JsonResponse
|
public function __invoke(Request $request, GitDeployment $deployment, ?string $token = null): JsonResponse
|
||||||
{
|
{
|
||||||
if (! hash_equals($deployment->secret_token, $token)) {
|
$payload = $request->getContent();
|
||||||
|
$providedSignature = (string) ($request->header('X-Jabali-Signature') ?? $request->header('X-Hub-Signature-256') ?? '');
|
||||||
|
$providedSignature = preg_replace('/^sha256=/i', '', trim($providedSignature)) ?: '';
|
||||||
|
$expectedSignature = hash_hmac('sha256', $payload, $deployment->secret_token);
|
||||||
|
|
||||||
|
$hasValidSignature = $providedSignature !== '' && hash_equals($expectedSignature, $providedSignature);
|
||||||
|
$hasValidLegacyToken = $token !== null && hash_equals($deployment->secret_token, $token);
|
||||||
|
|
||||||
|
if (! $hasValidSignature && ! $hasValidLegacyToken) {
|
||||||
return response()->json(['message' => 'Invalid token'], 403);
|
return response()->json(['message' => 'Invalid token'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,3 +185,53 @@ class BackupSchedule extends Model
|
|||||||
return $timezone;
|
return $timezone;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope for active schedules.
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope for due schedules.
|
||||||
|
*/
|
||||||
|
public function scopeDue($query)
|
||||||
|
{
|
||||||
|
return $query->active()
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('next_run_at')
|
||||||
|
->orWhere('next_run_at', '<=', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope for user schedules.
|
||||||
|
*/
|
||||||
|
public function scopeForUser($query, int $userId)
|
||||||
|
{
|
||||||
|
return $query->where('user_id', $userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope for server backup schedules.
|
||||||
|
*/
|
||||||
|
public function scopeServerBackups($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_server_backup', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get last status color for UI.
|
||||||
|
*/
|
||||||
|
public function getLastStatusColorAttribute(): string
|
||||||
|
{
|
||||||
|
return match ($this->last_status) {
|
||||||
|
'success' => 'success',
|
||||||
|
'failed' => 'danger',
|
||||||
|
default => 'gray',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
|
\Log::warning("Failed to delete email forwarders for user {$user->username}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,32 +100,37 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
$masterUser = $user->username.'_admin';
|
$masterUser = $user->username.'_admin';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Use credentials from environment variables
|
if (class_exists(\mysqli::class)) {
|
||||||
$mysqli = new \mysqli(
|
// Use credentials from environment variables
|
||||||
config('database.connections.mysql.host', 'localhost'),
|
$mysqli = new \mysqli(
|
||||||
config('database.connections.mysql.username'),
|
config('database.connections.mysql.host', 'localhost'),
|
||||||
config('database.connections.mysql.password')
|
config('database.connections.mysql.username'),
|
||||||
);
|
config('database.connections.mysql.password')
|
||||||
|
);
|
||||||
|
|
||||||
if (! $mysqli->connect_error) {
|
if (! $mysqli->connect_error) {
|
||||||
// Use prepared statement to prevent SQL injection
|
// Use prepared statement to prevent SQL injection
|
||||||
// MySQL doesn't support prepared statements for DROP USER,
|
// MySQL doesn't support prepared statements for DROP USER,
|
||||||
// so we validate the username format strictly
|
// so we validate the username format strictly
|
||||||
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
|
if (! preg_match('/^[a-zA-Z0-9_]+$/', $masterUser)) {
|
||||||
throw new \Exception('Invalid MySQL username format');
|
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();
|
|
||||||
}
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
// Delete stored credentials
|
|
||||||
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
|
\Log::error('Failed to delete master MySQL user: '.$e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
\App\Models\MysqlCredential::where('user_id', $user->id)->delete();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('Failed to delete stored MySQL credentials: '.$e->getMessage());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,28 +164,25 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
*/
|
*/
|
||||||
public function getDiskUsageBytes(): int
|
public function getDiskUsageBytes(): int
|
||||||
{
|
{
|
||||||
// Try to get usage from quota system first (more accurate)
|
// Disk usage must be obtained via the agent (root) to avoid permission-based undercounting.
|
||||||
try {
|
try {
|
||||||
$agent = new \App\Services\Agent\AgentClient;
|
$agent = new \App\Services\Agent\AgentClient(
|
||||||
$result = $agent->quotaGet($this->username, '/');
|
(string) config('jabali.agent.socket', '/var/run/jabali/agent.sock'),
|
||||||
|
(int) config('jabali.agent.timeout', 120),
|
||||||
|
);
|
||||||
|
|
||||||
|
$mount = $this->home_directory ?: ("/home/{$this->username}");
|
||||||
|
$result = $agent->quotaGet($this->username, $mount);
|
||||||
|
|
||||||
if (($result['success'] ?? false) && isset($result['used_mb'])) {
|
if (($result['success'] ?? false) && isset($result['used_mb'])) {
|
||||||
return (int) ($result['used_mb'] * 1024 * 1024);
|
return (int) ($result['used_mb'] * 1024 * 1024);
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
// Fall back to du command
|
\Log::warning('Disk usage read failed via agent: '.$e->getMessage(), [
|
||||||
|
'username' => $this->username,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
return 0;
|
||||||
// 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ namespace App\Providers;
|
|||||||
use App\Models\Domain;
|
use App\Models\Domain;
|
||||||
use App\Observers\DomainObserver;
|
use App\Observers\DomainObserver;
|
||||||
use Filament\Support\Facades\FilamentAsset;
|
use Filament\Support\Facades\FilamentAsset;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Register any application services.
|
* Register any application services.
|
||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void {}
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bootstrap any application services.
|
* Bootstrap any application services.
|
||||||
@@ -24,6 +25,31 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
Domain::observe(DomainObserver::class);
|
Domain::observe(DomainObserver::class);
|
||||||
|
|
||||||
|
RateLimiter::for('api', function (Request $request): array {
|
||||||
|
$identifier = $request->user()?->getAuthIdentifier() ?? $request->ip();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(120)->by('api:'.$identifier),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('internal-api', function (Request $request): array {
|
||||||
|
$remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip());
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(60)->by('internal:'.$remoteAddr),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
RateLimiter::for('git-webhooks', function (Request $request): array {
|
||||||
|
$deploymentId = $request->route('deployment');
|
||||||
|
$deploymentKey = is_object($deploymentId) ? (string) $deploymentId->getKey() : (string) $deploymentId;
|
||||||
|
|
||||||
|
return [
|
||||||
|
Limit::perMinute(120)->by('webhook:'.$deploymentKey.':'.$request->ip()),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
$versionFile = base_path('VERSION');
|
$versionFile = base_path('VERSION');
|
||||||
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
|
$appVersion = File::exists($versionFile) ? trim(File::get($versionFile)) : null;
|
||||||
FilamentAsset::appVersion($appVersion ?: null);
|
FilamentAsset::appVersion($appVersion ?: null);
|
||||||
|
|||||||
@@ -504,13 +504,19 @@ class AgentClient
|
|||||||
return $this->send('wp.import', $params);
|
return $this->send('wp.import', $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function wpCreateStaging(string $username, string $siteId, string $subdomain): array
|
public function wpCreateStaging(string $username, string $siteId, string $subdomain = 'staging', ?string $targetDomain = null): array
|
||||||
{
|
{
|
||||||
return $this->send('wp.create_staging', [
|
$params = [
|
||||||
'username' => $username,
|
'username' => $username,
|
||||||
'site_id' => $siteId,
|
'site_id' => $siteId,
|
||||||
'subdomain' => $subdomain,
|
'subdomain' => $subdomain,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($targetDomain !== null && $targetDomain !== '') {
|
||||||
|
$params['target_domain'] = $targetDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->send('wp.create_staging', $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function wpPushStaging(string $username, string $stagingSiteId): array
|
public function wpPushStaging(string $username, string $stagingSiteId): array
|
||||||
|
|||||||
916
bin/jabali-agent
916
bin/jabali-agent
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@@ -12,7 +13,24 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->trustProxies(at: '*');
|
$trustedProxies = env('TRUSTED_PROXIES');
|
||||||
|
$resolvedProxies = match (true) {
|
||||||
|
is_string($trustedProxies) && trim($trustedProxies) === '*' => '*',
|
||||||
|
is_string($trustedProxies) && trim($trustedProxies) !== '' => array_values(array_filter(array_map(
|
||||||
|
static fn (string $proxy): string => trim($proxy),
|
||||||
|
explode(',', $trustedProxies)
|
||||||
|
))),
|
||||||
|
default => ['127.0.0.1', '::1'],
|
||||||
|
};
|
||||||
|
|
||||||
|
$middleware->trustProxies(
|
||||||
|
at: $resolvedProxies,
|
||||||
|
headers: Request::HEADER_X_FORWARDED_FOR
|
||||||
|
| Request::HEADER_X_FORWARDED_HOST
|
||||||
|
| Request::HEADER_X_FORWARDED_PORT
|
||||||
|
| Request::HEADER_X_FORWARDED_PROTO
|
||||||
|
);
|
||||||
|
$middleware->throttleApi('api');
|
||||||
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
$middleware->append(\App\Http\Middleware\SecurityHeaders::class);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
"filament/blueprint": "^2.0",
|
"filament/blueprint": "^2.1",
|
||||||
"laravel/boost": "*",
|
"laravel/boost": "*",
|
||||||
"laravel/pail": "^1.2.2",
|
"laravel/pail": "^1.2.2",
|
||||||
"laravel/pint": "^1.24",
|
"laravel/pint": "^1.24",
|
||||||
|
|||||||
127
composer.lock
generated
127
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "194d87cc129a30c6e832109fb820097a",
|
"content-hash": "7083b0b087c4b503b50d3aa23cfbbfac",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -5286,16 +5286,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "psy/psysh",
|
"name": "psy/psysh",
|
||||||
"version": "v0.12.18",
|
"version": "v0.12.20",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/bobthecow/psysh.git",
|
"url": "https://github.com/bobthecow/psysh.git",
|
||||||
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196"
|
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/ddff0ac01beddc251786fe70367cd8bbdb258196",
|
"url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373",
|
||||||
"reference": "ddff0ac01beddc251786fe70367cd8bbdb258196",
|
"reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -5359,9 +5359,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/bobthecow/psysh/issues",
|
"issues": "https://github.com/bobthecow/psysh/issues",
|
||||||
"source": "https://github.com/bobthecow/psysh/tree/v0.12.18"
|
"source": "https://github.com/bobthecow/psysh/tree/v0.12.20"
|
||||||
},
|
},
|
||||||
"time": "2025-12-17T14:35:46+00:00"
|
"time": "2026-02-11T15:05:28+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ralouphie/getallheaders",
|
"name": "ralouphie/getallheaders",
|
||||||
@@ -5981,16 +5981,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/console",
|
"name": "symfony/console",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/console.git",
|
"url": "https://github.com/symfony/console.git",
|
||||||
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6"
|
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/console/zipball/732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
|
"url": "https://api.github.com/repos/symfony/console/zipball/41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
|
||||||
"reference": "732a9ca6cd9dfd940c639062d5edbde2f6727fb6",
|
"reference": "41e38717ac1dd7a46b6bda7d6a82af2d98a78894",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -6055,7 +6055,7 @@
|
|||||||
"terminal"
|
"terminal"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/console/tree/v7.4.3"
|
"source": "https://github.com/symfony/console/tree/v7.4.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -6075,7 +6075,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-23T14:50:43+00:00"
|
"time": "2026-01-13T11:36:38+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/css-selector",
|
"name": "symfony/css-selector",
|
||||||
@@ -7803,16 +7803,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/process",
|
"name": "symfony/process",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.5",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/process.git",
|
"url": "https://github.com/symfony/process.git",
|
||||||
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f"
|
"reference": "608476f4604102976d687c483ac63a79ba18cc97"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/process/zipball/2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
|
"url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
|
||||||
"reference": "2f8e1a6cdf590ca63715da4d3a7a3327404a523f",
|
"reference": "608476f4604102976d687c483ac63a79ba18cc97",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -7844,7 +7844,7 @@
|
|||||||
"description": "Executes commands in sub-processes",
|
"description": "Executes commands in sub-processes",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/process/tree/v7.4.3"
|
"source": "https://github.com/symfony/process/tree/v7.4.5"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -7864,7 +7864,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-19T10:00:43+00:00"
|
"time": "2026-01-26T15:07:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/routing",
|
"name": "symfony/routing",
|
||||||
@@ -8040,16 +8040,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/string",
|
"name": "symfony/string",
|
||||||
"version": "v8.0.1",
|
"version": "v8.0.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/string.git",
|
"url": "https://github.com/symfony/string.git",
|
||||||
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc"
|
"reference": "758b372d6882506821ed666032e43020c4f57194"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/string/zipball/ba65a969ac918ce0cc3edfac6cdde847eba231dc",
|
"url": "https://api.github.com/repos/symfony/string/zipball/758b372d6882506821ed666032e43020c4f57194",
|
||||||
"reference": "ba65a969ac918ce0cc3edfac6cdde847eba231dc",
|
"reference": "758b372d6882506821ed666032e43020c4f57194",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -8106,7 +8106,7 @@
|
|||||||
"utf8"
|
"utf8"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/string/tree/v8.0.1"
|
"source": "https://github.com/symfony/string/tree/v8.0.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8126,7 +8126,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-01T09:13:36+00:00"
|
"time": "2026-01-12T12:37:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/translation",
|
"name": "symfony/translation",
|
||||||
@@ -8383,16 +8383,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/var-dumper",
|
"name": "symfony/var-dumper",
|
||||||
"version": "v7.4.3",
|
"version": "v7.4.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/var-dumper.git",
|
"url": "https://github.com/symfony/var-dumper.git",
|
||||||
"reference": "7e99bebcb3f90d8721890f2963463280848cba92"
|
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/7e99bebcb3f90d8721890f2963463280848cba92",
|
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/0e4769b46a0c3c62390d124635ce59f66874b282",
|
||||||
"reference": "7e99bebcb3f90d8721890f2963463280848cba92",
|
"reference": "0e4769b46a0c3c62390d124635ce59f66874b282",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -8446,7 +8446,7 @@
|
|||||||
"dump"
|
"dump"
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/var-dumper/tree/v7.4.3"
|
"source": "https://github.com/symfony/var-dumper/tree/v7.4.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -8466,7 +8466,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-12-18T07:04:31+00:00"
|
"time": "2026-01-01T22:13:48+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
@@ -8817,14 +8817,14 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "filament/blueprint",
|
"name": "filament/blueprint",
|
||||||
"version": "v2.0.1",
|
"version": "v2.1.0",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://packages.filamentphp.com/composer/10/127/download"
|
"url": "https://packages.filamentphp.com/composer/10/473/download"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"filament/support": "^5.0",
|
"filament/support": "^5.0",
|
||||||
"laravel/boost": "^1.8"
|
"laravel/boost": "^1.8|^2.0"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"license": [
|
"license": [
|
||||||
@@ -9820,28 +9820,28 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-file-iterator",
|
"name": "phpunit/php-file-iterator",
|
||||||
"version": "5.1.0",
|
"version": "5.1.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
||||||
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6"
|
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6",
|
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
|
||||||
"reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6",
|
"reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2"
|
"php": ">=8.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^11.0"
|
"phpunit/phpunit": "^11.3"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "5.0-dev"
|
"dev-main": "5.1-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -9869,15 +9869,27 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
"issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
"security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0"
|
"source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"url": "https://github.com/sebastianbergmann",
|
"url": "https://github.com/sebastianbergmann",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://liberapay.com/sebastianbergmann",
|
||||||
|
"type": "liberapay"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://thanks.dev/u/gh/sebastianbergmann",
|
||||||
|
"type": "thanks_dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
|
||||||
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2024-08-27T05:02:59+00:00"
|
"time": "2026-02-02T13:52:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-invoker",
|
"name": "phpunit/php-invoker",
|
||||||
@@ -10065,16 +10077,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "11.5.48",
|
"version": "11.5.53",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89"
|
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fe3665c15e37140f55aaf658c81a2eb9030b6d89",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||||
"reference": "fe3665c15e37140f55aaf658c81a2eb9030b6d89",
|
"reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10089,18 +10101,19 @@
|
|||||||
"phar-io/version": "^3.2.1",
|
"phar-io/version": "^3.2.1",
|
||||||
"php": ">=8.2",
|
"php": ">=8.2",
|
||||||
"phpunit/php-code-coverage": "^11.0.12",
|
"phpunit/php-code-coverage": "^11.0.12",
|
||||||
"phpunit/php-file-iterator": "^5.1.0",
|
"phpunit/php-file-iterator": "^5.1.1",
|
||||||
"phpunit/php-invoker": "^5.0.1",
|
"phpunit/php-invoker": "^5.0.1",
|
||||||
"phpunit/php-text-template": "^4.0.1",
|
"phpunit/php-text-template": "^4.0.1",
|
||||||
"phpunit/php-timer": "^7.0.1",
|
"phpunit/php-timer": "^7.0.1",
|
||||||
"sebastian/cli-parser": "^3.0.2",
|
"sebastian/cli-parser": "^3.0.2",
|
||||||
"sebastian/code-unit": "^3.0.3",
|
"sebastian/code-unit": "^3.0.3",
|
||||||
"sebastian/comparator": "^6.3.2",
|
"sebastian/comparator": "^6.3.3",
|
||||||
"sebastian/diff": "^6.0.2",
|
"sebastian/diff": "^6.0.2",
|
||||||
"sebastian/environment": "^7.2.1",
|
"sebastian/environment": "^7.2.1",
|
||||||
"sebastian/exporter": "^6.3.2",
|
"sebastian/exporter": "^6.3.2",
|
||||||
"sebastian/global-state": "^7.0.2",
|
"sebastian/global-state": "^7.0.2",
|
||||||
"sebastian/object-enumerator": "^6.0.1",
|
"sebastian/object-enumerator": "^6.0.1",
|
||||||
|
"sebastian/recursion-context": "^6.0.3",
|
||||||
"sebastian/type": "^5.1.3",
|
"sebastian/type": "^5.1.3",
|
||||||
"sebastian/version": "^5.0.2",
|
"sebastian/version": "^5.0.2",
|
||||||
"staabm/side-effects-detector": "^1.0.5"
|
"staabm/side-effects-detector": "^1.0.5"
|
||||||
@@ -10146,7 +10159,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.48"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10170,7 +10183,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-01-16T16:26:27+00:00"
|
"time": "2026-02-10T12:28:25+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
@@ -10344,16 +10357,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/comparator",
|
"name": "sebastian/comparator",
|
||||||
"version": "6.3.2",
|
"version": "6.3.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||||
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8"
|
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8",
|
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
|
||||||
"reference": "85c77556683e6eee4323e4c5468641ca0237e2e8",
|
"reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10412,7 +10425,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
"issues": "https://github.com/sebastianbergmann/comparator/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2"
|
"source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10432,7 +10445,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2025-08-10T08:07:46+00:00"
|
"time": "2026-01-24T09:26:40+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/complexity",
|
"name": "sebastian/complexity",
|
||||||
@@ -11346,5 +11359,5 @@
|
|||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
26
config.toml.example
Normal file
26
config.toml.example
Normal 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"
|
||||||
@@ -123,4 +123,15 @@ return [
|
|||||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Internal API Token
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Optional shared token for internal endpoints that may be called from
|
||||||
|
| non-localhost environments (for example, when using a reverse proxy).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
'internal_api_token' => env('JABALI_INTERNAL_API_TOKEN'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ return [
|
|||||||
'root' => '/tmp',
|
'root' => '/tmp',
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Server-wide backups folder (created by install.sh)
|
||||||
|
'backups' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => env('JABALI_BACKUPS_ROOT', '/var/backups/jabali'),
|
||||||
|
'throw' => false,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
'links' => [
|
'links' => [
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ This page provides git deployment features for the jabali panel.
|
|||||||
- Use git deployment to complete common operational tasks.
|
- Use git deployment to complete common operational tasks.
|
||||||
- Review this page after configuration changes to confirm results.
|
- Review this page after configuration changes to confirm results.
|
||||||
|
|
||||||
|
## Webhook Security
|
||||||
|
|
||||||
|
- Use the provided `Webhook URL` with the `Webhook Secret`.
|
||||||
|
- Jabali validates `X-Jabali-Signature` (or `X-Hub-Signature-256`) as HMAC-SHA256 over the raw request body.
|
||||||
|
- Legacy tokenized webhook URLs remain supported for older integrations.
|
||||||
|
|
||||||
## Typical examples
|
## Typical examples
|
||||||
|
|
||||||
- Example 1: Use git deployment to complete common operational tasks.
|
- Example 1: Use git deployment to complete common operational tasks.
|
||||||
|
|||||||
277
docs/architecture/directadmin-migration-blueprint.md
Normal file
277
docs/architecture/directadmin-migration-blueprint.md
Normal 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.
|
||||||
|
|
||||||
150
docs/architecture/mcp-and-filament-blueprint.md
Normal file
150
docs/architecture/mcp-and-filament-blueprint.md
Normal 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.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Jabali Documentation Index
|
# Jabali Documentation Index
|
||||||
|
|
||||||
Last updated: 2026-02-06
|
Last updated: 2026-02-10
|
||||||
|
|
||||||
## Top-Level Docs
|
## Top-Level Docs
|
||||||
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
|
- /var/www/jabali/README.md - Product overview, features, install, upgrade, and architecture summary.
|
||||||
@@ -11,8 +11,10 @@ Last updated: 2026-02-06
|
|||||||
- /var/www/jabali/TODO.md - Active checklist items.
|
- /var/www/jabali/TODO.md - Active checklist items.
|
||||||
|
|
||||||
## Docs Folder
|
## Docs Folder
|
||||||
- /var/www/jabali/docs/installation.md - Debian package install path and Filament notifications patch.
|
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
|
||||||
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
|
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
|
||||||
|
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
|
||||||
|
- /var/www/jabali/docs/architecture/mcp-and-filament-blueprint.md - Developer blueprint for MCP tooling and Filament panel conventions.
|
||||||
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
|
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
|
||||||
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
|
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
|
||||||
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).
|
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Documentation Summary (Jabali Panel)
|
# Documentation Summary (Jabali Panel)
|
||||||
|
|
||||||
Last updated: 2026-02-06
|
Last updated: 2026-02-10
|
||||||
|
|
||||||
## Product Overview
|
## Product Overview
|
||||||
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
|
Jabali Panel is a modern web hosting control panel for WordPress and general PHP hosting. It provides an admin panel for server-wide operations and a user panel for per-tenant management. The core goals are safe automation, clean multi-tenant isolation, and operational clarity.
|
||||||
@@ -41,6 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
|
|||||||
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
|
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
|
||||||
- Installer: install.sh, builds assets as www-data and ensures permissions.
|
- Installer: install.sh, builds assets as www-data and ensures permissions.
|
||||||
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
|
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
|
||||||
|
- Deploy helper: scripts/deploy.sh rsyncs to `root@192.168.100.50`, commits there, bumps VERSION, updates install.sh fallback, pushes to Git remotes from that server, then runs composer/npm, migrations, and caches.
|
||||||
|
|
||||||
## Packaging
|
## Packaging
|
||||||
Debian packaging is supported via scripts:
|
Debian packaging is supported via scripts:
|
||||||
@@ -52,5 +53,6 @@ mcp-docs-server exposes README, AGENT docs, and changelog through MCP tools for
|
|||||||
|
|
||||||
## Miscellaneous Docs
|
## Miscellaneous Docs
|
||||||
- Screenshot regeneration script: tests/take-screenshots.cjs.
|
- Screenshot regeneration script: tests/take-screenshots.cjs.
|
||||||
|
- DirectAdmin migration blueprint: docs/architecture/directadmin-migration-blueprint.md.
|
||||||
- Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders.
|
- Policies: resources/markdown/policy.md and resources/markdown/terms.md are placeholders.
|
||||||
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.
|
- WordPress plugin: resources/wordpress/jabali-cache/readme.txt documents the Jabali Cache plugin.
|
||||||
|
|||||||
@@ -110,6 +110,71 @@ What is included:
|
|||||||
If you update or rebuild assets, keep the guard in place and hard‑refresh the
|
If you update or rebuild assets, keep the guard in place and hard‑refresh the
|
||||||
browser (Ctrl+Shift+R) after deployment.
|
browser (Ctrl+Shift+R) after deployment.
|
||||||
|
|
||||||
|
## Deploy script
|
||||||
|
|
||||||
|
The repository ships with a deploy helper at `scripts/deploy.sh`. It rsyncs the
|
||||||
|
project to `root@192.168.100.50:/var/www/jabali`, commits on that server, bumps
|
||||||
|
`VERSION`, updates the `install.sh` fallback, and pushes to Git remotes from
|
||||||
|
that server. Then it runs composer/npm, migrations, and cache rebuilds.
|
||||||
|
|
||||||
|
Defaults (override via flags, env vars, or config.toml):
|
||||||
|
|
||||||
|
Config file:
|
||||||
|
- `config.toml` (ignored by git) is read automatically if present.
|
||||||
|
- Start from `config.toml.example`.
|
||||||
|
- Set `CONFIG_FILE` to use an alternate TOML file path.
|
||||||
|
- Supported keys are in `[deploy]` (for example: `host`, `user`, `path`, `www_user`, `push_branch`, `gitea_url`, `github_url`).
|
||||||
|
|
||||||
|
- Host: `192.168.100.50`
|
||||||
|
- User: `root`
|
||||||
|
- Path: `/var/www/jabali`
|
||||||
|
- Web user: `www-data`
|
||||||
|
|
||||||
|
Common usage:
|
||||||
|
```
|
||||||
|
# Basic deploy to the default host
|
||||||
|
scripts/deploy.sh
|
||||||
|
|
||||||
|
# Target a different host/path/user
|
||||||
|
scripts/deploy.sh --host 192.168.100.50 --user root --path /var/www/jabali --www-user www-data
|
||||||
|
|
||||||
|
# Dry-run rsync only
|
||||||
|
scripts/deploy.sh --dry-run
|
||||||
|
|
||||||
|
# Skip npm build and cache steps
|
||||||
|
scripts/deploy.sh --skip-npm --skip-cache
|
||||||
|
```
|
||||||
|
|
||||||
|
Push behavior controls:
|
||||||
|
```
|
||||||
|
# Deploy only (no push)
|
||||||
|
scripts/deploy.sh --skip-push
|
||||||
|
|
||||||
|
# Push only one remote
|
||||||
|
scripts/deploy.sh --no-push-github
|
||||||
|
scripts/deploy.sh --no-push-gitea
|
||||||
|
|
||||||
|
# Push to explicit URLs from the test server
|
||||||
|
scripts/deploy.sh --gitea-url ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git \
|
||||||
|
--github-url git@github.com:shukiv/jabali-panel.git
|
||||||
|
```
|
||||||
|
|
||||||
|
GitHub push location:
|
||||||
|
```
|
||||||
|
# Push to GitHub from the test server (required)
|
||||||
|
ssh root@192.168.100.50
|
||||||
|
cd /var/www/jabali
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Pushes run from `root@192.168.100.50` (not from local machine).
|
||||||
|
- Before each push, the script bumps `VERSION`, updates `install.sh` fallback,
|
||||||
|
and commits on the deploy server.
|
||||||
|
- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
|
||||||
|
`bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
|
||||||
|
- `--delete` passes `--delete` to rsync (dangerous).
|
||||||
|
|
||||||
## Testing after changes
|
## Testing after changes
|
||||||
|
|
||||||
After every change, run a test to make sure there are no errors.
|
After every change, run a test to make sure there are no errors.
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ From /var/www/jabali:
|
|||||||
- Do not push unless explicitly asked.
|
- Do not push unless explicitly asked.
|
||||||
- Bump VERSION before every push.
|
- Bump VERSION before every push.
|
||||||
- Keep install.sh version fallback in sync with VERSION.
|
- Keep install.sh version fallback in sync with VERSION.
|
||||||
|
- Push to GitHub from `root@192.168.100.50`.
|
||||||
|
|
||||||
## Where to Look for Examples
|
## Where to Look for Examples
|
||||||
- app/Filament/Admin/Pages and app/Filament/Jabali/Pages
|
- app/Filament/Admin/Pages and app/Filament/Jabali/Pages
|
||||||
|
|||||||
26
install.sh
26
install.sh
@@ -16,7 +16,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
if [[ -f "$SCRIPT_DIR/VERSION" ]]; then
|
||||||
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
JABALI_VERSION="$(sed -n 's/^VERSION=//p' "$SCRIPT_DIR/VERSION")"
|
||||||
fi
|
fi
|
||||||
JABALI_VERSION="${JABALI_VERSION:-0.9-rc54}"
|
JABALI_VERSION="${JABALI_VERSION:-0.9-rc66}"
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -414,6 +414,7 @@ install_packages() {
|
|||||||
wget
|
wget
|
||||||
zip
|
zip
|
||||||
unzip
|
unzip
|
||||||
|
cron
|
||||||
htop
|
htop
|
||||||
net-tools
|
net-tools
|
||||||
dnsutils
|
dnsutils
|
||||||
@@ -2945,8 +2946,9 @@ EOF
|
|||||||
mkdir -p /var/backups/jabali
|
mkdir -p /var/backups/jabali
|
||||||
mkdir -p /var/backups/jabali/cpanel-migrations
|
mkdir -p /var/backups/jabali/cpanel-migrations
|
||||||
mkdir -p /var/backups/jabali/whm-migrations
|
mkdir -p /var/backups/jabali/whm-migrations
|
||||||
|
mkdir -p /var/backups/jabali/directadmin-migrations
|
||||||
chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali
|
chown -R $JABALI_USER:$JABALI_USER /var/backups/jabali
|
||||||
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations
|
chmod 755 /var/backups/jabali /var/backups/jabali/cpanel-migrations /var/backups/jabali/whm-migrations /var/backups/jabali/directadmin-migrations
|
||||||
|
|
||||||
log "Jabali Panel setup complete"
|
log "Jabali Panel setup complete"
|
||||||
}
|
}
|
||||||
@@ -3019,6 +3021,18 @@ setup_scheduler_cron() {
|
|||||||
mkdir -p "$JABALI_DIR/storage/logs"
|
mkdir -p "$JABALI_DIR/storage/logs"
|
||||||
chown -R www-data:www-data "$JABALI_DIR/storage/logs"
|
chown -R www-data:www-data "$JABALI_DIR/storage/logs"
|
||||||
|
|
||||||
|
# Ensure crontab command is available
|
||||||
|
if ! command -v crontab >/dev/null 2>&1; then
|
||||||
|
warn "crontab command not found, installing cron package..."
|
||||||
|
apt-get update -qq
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq cron || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v crontab >/dev/null 2>&1; then
|
||||||
|
warn "Unable to configure scheduler: crontab command is still missing"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure cron service is enabled and running
|
# Ensure cron service is enabled and running
|
||||||
if command -v systemctl >/dev/null 2>&1; then
|
if command -v systemctl >/dev/null 2>&1; then
|
||||||
systemctl enable cron >/dev/null 2>&1 || true
|
systemctl enable cron >/dev/null 2>&1 || true
|
||||||
@@ -3029,8 +3043,8 @@ setup_scheduler_cron() {
|
|||||||
CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1"
|
CRON_LINE="* * * * * cd $JABALI_DIR && php artisan schedule:run >> /dev/null 2>&1"
|
||||||
|
|
||||||
# Add to www-data's crontab (not root) to avoid permission issues with log files
|
# Add to www-data's crontab (not root) to avoid permission issues with log files
|
||||||
if ! sudo -u www-data crontab -l 2>/dev/null | grep -q "artisan schedule:run"; then
|
if ! crontab -u www-data -l 2>/dev/null | grep -q "artisan schedule:run"; then
|
||||||
(sudo -u www-data crontab -l 2>/dev/null; echo "$CRON_LINE") | sudo -u www-data crontab -
|
(crontab -u www-data -l 2>/dev/null; echo "$CRON_LINE") | crontab -u www-data -
|
||||||
log "Laravel scheduler cron job added"
|
log "Laravel scheduler cron job added"
|
||||||
else
|
else
|
||||||
log "Laravel scheduler cron job already exists"
|
log "Laravel scheduler cron job already exists"
|
||||||
@@ -3616,7 +3630,9 @@ uninstall() {
|
|||||||
rm -f /etc/logrotate.d/jabali-users
|
rm -f /etc/logrotate.d/jabali-users
|
||||||
|
|
||||||
# Remove www-data cron jobs (Laravel scheduler)
|
# Remove www-data cron jobs (Laravel scheduler)
|
||||||
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"
|
log "Configuration files cleaned"
|
||||||
|
|
||||||
|
|||||||
16
lang/ar.json
16
lang/ar.json
@@ -219,6 +219,20 @@
|
|||||||
"Disk Usage": "استخدام القرص",
|
"Disk Usage": "استخدام القرص",
|
||||||
"Display Name": "الاسم المعروض",
|
"Display Name": "الاسم المعروض",
|
||||||
"Document Root": "المجلد الجذر",
|
"Document Root": "المجلد الجذر",
|
||||||
|
"Documentation": "التوثيق",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "اعثر على إجابات في التوثيق أو تحدث مع روبوت الدعم المدرَّب لدينا. اطّلع على أدلة الإعداد وخطوات استكشاف الأخطاء وأفضل الممارسات.",
|
||||||
|
"Chat with our AI support bot.": "تحدث مع روبوت الدعم بالذكاء الاصطناعي.",
|
||||||
|
"GitHub Issues": "مشكلات GitHub",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "أبلغ عن الأخطاء أو اطلب الميزات. أرفق الخطوات والسجلات ولقطات الشاشة لنتمكن من إعادة الإنتاج بسرعة.",
|
||||||
|
"Open GitHub Issues": "فتح مشكلات GitHub",
|
||||||
|
"Paid Support": "دعم مدفوع",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "احصل على مساعدة احترافية للترحيل وتحسين الأداء والإصلاحات ذات الأولوية. تتضمن الخطط الإعداد والدعم المخصص.",
|
||||||
|
"View Support Plans": "عرض خطط الدعم",
|
||||||
|
"Response Time": "وقت الاستجابة",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "نستجيب عادة خلال 4-8 ساعات. للحوادث الحرجة، استخدم دعم الطوارئ للحصول على استجابة أسرع.",
|
||||||
|
"Emergency Support": "دعم الطوارئ",
|
||||||
|
"Open Documentation": "فتح التوثيق",
|
||||||
|
"Support Chat": "دردشة الدعم",
|
||||||
"Domain": "نطاق",
|
"Domain": "نطاق",
|
||||||
"Domain Name": "اسم النطاق",
|
"Domain Name": "اسم النطاق",
|
||||||
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",
|
"Domain Verification Code (optional)": "رمز التحقق من النطاق (اختياري)",
|
||||||
@@ -879,4 +893,4 @@
|
|||||||
"results": "نتائج",
|
"results": "نتائج",
|
||||||
"selected": "محدد",
|
"selected": "محدد",
|
||||||
"to": "إلى"
|
"to": "إلى"
|
||||||
}
|
}
|
||||||
|
|||||||
13
lang/en.json
13
lang/en.json
@@ -782,6 +782,19 @@
|
|||||||
"Display Name": "Display Name",
|
"Display Name": "Display Name",
|
||||||
"Document Root": "Document Root",
|
"Document Root": "Document Root",
|
||||||
"Documentation": "Documentation",
|
"Documentation": "Documentation",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.",
|
||||||
|
"Chat with our AI support bot.": "Chat with our AI support bot.",
|
||||||
|
"GitHub Issues": "GitHub Issues",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.",
|
||||||
|
"Open GitHub Issues": "Open GitHub Issues",
|
||||||
|
"Paid Support": "Paid Support",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.",
|
||||||
|
"View Support Plans": "View Support Plans",
|
||||||
|
"Response Time": "Response Time",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.",
|
||||||
|
"Emergency Support": "Emergency Support",
|
||||||
|
"Open Documentation": "Open Documentation",
|
||||||
|
"Support Chat": "Support Chat",
|
||||||
"Domain": "Domain",
|
"Domain": "Domain",
|
||||||
"Domain Aliases": "Domain Aliases",
|
"Domain Aliases": "Domain Aliases",
|
||||||
"Domain Certificates": "Domain Certificates",
|
"Domain Certificates": "Domain Certificates",
|
||||||
|
|||||||
16
lang/es.json
16
lang/es.json
@@ -308,6 +308,20 @@
|
|||||||
"Disk Usage": "Uso de disco",
|
"Disk Usage": "Uso de disco",
|
||||||
"Display Name": "Nombre para mostrar",
|
"Display Name": "Nombre para mostrar",
|
||||||
"Document Root": "Raíz del documento",
|
"Document Root": "Raíz del documento",
|
||||||
|
"Documentation": "Documentación",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encuentra respuestas en nuestra documentación o habla con nuestro bot de soporte entrenado. Explora guías de configuración, pasos de solución de problemas y buenas prácticas.",
|
||||||
|
"Chat with our AI support bot.": "Chatea con nuestro bot de soporte con IA.",
|
||||||
|
"GitHub Issues": "Issues de GitHub",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporta errores o solicita funciones. Incluye pasos, registros y capturas de pantalla para que podamos reproducirlo rápido.",
|
||||||
|
"Open GitHub Issues": "Abrir issues de GitHub",
|
||||||
|
"Paid Support": "Soporte de pago",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtén asistencia profesional para migraciones, ajustes de rendimiento y correcciones prioritarias. Los planes incluyen incorporación y soporte dedicado.",
|
||||||
|
"View Support Plans": "Ver planes de soporte",
|
||||||
|
"Response Time": "Tiempo de respuesta",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos en 4-8 horas. Para incidentes críticos, usa el Soporte de Emergencia para una respuesta más rápida.",
|
||||||
|
"Emergency Support": "Soporte de emergencia",
|
||||||
|
"Open Documentation": "Abrir documentación",
|
||||||
|
"Support Chat": "Chat de soporte",
|
||||||
"Domain": "Dominio",
|
"Domain": "Dominio",
|
||||||
"Domain Configuration": "Configuración de dominio",
|
"Domain Configuration": "Configuración de dominio",
|
||||||
"Domain Count": "Cantidad de dominios",
|
"Domain Count": "Cantidad de dominios",
|
||||||
@@ -1170,4 +1184,4 @@
|
|||||||
"results": "resultados",
|
"results": "resultados",
|
||||||
"selected": "seleccionado(s)",
|
"selected": "seleccionado(s)",
|
||||||
"to": "a"
|
"to": "a"
|
||||||
}
|
}
|
||||||
|
|||||||
16
lang/fr.json
16
lang/fr.json
@@ -220,6 +220,20 @@
|
|||||||
"Disk Usage": "Utilisation du disque",
|
"Disk Usage": "Utilisation du disque",
|
||||||
"Display Name": "Nom d'affichage",
|
"Display Name": "Nom d'affichage",
|
||||||
"Document Root": "Racine du document",
|
"Document Root": "Racine du document",
|
||||||
|
"Documentation": "Documentation",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Trouvez des réponses dans notre documentation ou discutez avec notre bot d'assistance entraîné. Explorez les guides de configuration, les étapes de dépannage et les bonnes pratiques.",
|
||||||
|
"Chat with our AI support bot.": "Discutez avec notre bot de support IA.",
|
||||||
|
"GitHub Issues": "Issues GitHub",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Signalez des bugs ou demandez des fonctionnalités. Incluez les étapes, les journaux et des captures d'écran pour une reproduction rapide.",
|
||||||
|
"Open GitHub Issues": "Ouvrir les issues GitHub",
|
||||||
|
"Paid Support": "Support payant",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenez une assistance professionnelle pour les migrations, l'optimisation des performances et les correctifs prioritaires. Les plans incluent l'onboarding et un support dédié.",
|
||||||
|
"View Support Plans": "Voir les plans de support",
|
||||||
|
"Response Time": "Délai de réponse",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Nous répondons généralement sous 4-8 heures. Pour les incidents critiques, utilisez le support d'urgence pour une réponse plus rapide.",
|
||||||
|
"Emergency Support": "Support d'urgence",
|
||||||
|
"Open Documentation": "Ouvrir la documentation",
|
||||||
|
"Support Chat": "Chat de support",
|
||||||
"Domain": "Domaine",
|
"Domain": "Domaine",
|
||||||
"Domain Name": "Nom de domaine",
|
"Domain Name": "Nom de domaine",
|
||||||
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",
|
"Domain Verification Code (optional)": "Code de verification du domaine (optionnel)",
|
||||||
@@ -886,4 +900,4 @@
|
|||||||
"results": "resultats",
|
"results": "resultats",
|
||||||
"selected": "selectionne(s)",
|
"selected": "selectionne(s)",
|
||||||
"to": "a"
|
"to": "a"
|
||||||
}
|
}
|
||||||
|
|||||||
16
lang/he.json
16
lang/he.json
@@ -219,6 +219,20 @@
|
|||||||
"Disk Usage": "שימוש בדיסק",
|
"Disk Usage": "שימוש בדיסק",
|
||||||
"Display Name": "שם תצוגה",
|
"Display Name": "שם תצוגה",
|
||||||
"Document Root": "תיקיית שורש",
|
"Document Root": "תיקיית שורש",
|
||||||
|
"Documentation": "תיעוד",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "מצאו תשובות בתיעוד שלנו או דברו עם בוט התמיכה המאומן שלנו. עיינו במדריכי התקנה, שלבי פתרון תקלות ושיטות עבודה מומלצות.",
|
||||||
|
"Chat with our AI support bot.": "שוחחו עם בוט התמיכה שלנו.",
|
||||||
|
"GitHub Issues": "Issues של GitHub",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "דווחו על באגים או בקשו פיצ'רים. צרפו שלבים, לוגים וצילומי מסך כדי שנוכל לשחזר במהירות.",
|
||||||
|
"Open GitHub Issues": "פתח Issues ב-GitHub",
|
||||||
|
"Paid Support": "תמיכה בתשלום",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "קבלו סיוע מקצועי במיגרציות, שיפור ביצועים ותיקונים בעדיפות גבוהה. התוכניות כוללות קליטה ותמיכה ייעודית.",
|
||||||
|
"View Support Plans": "צפו בתוכניות התמיכה",
|
||||||
|
"Response Time": "זמן תגובה",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "אנחנו בדרך כלל מגיבים תוך 4-8 שעות. לאירועים קריטיים, השתמשו בתמיכת חירום לקבלת מענה מהיר יותר.",
|
||||||
|
"Emergency Support": "תמיכת חירום",
|
||||||
|
"Open Documentation": "פתח תיעוד",
|
||||||
|
"Support Chat": "צ'אט תמיכה",
|
||||||
"Domain": "דומיין",
|
"Domain": "דומיין",
|
||||||
"Domain Name": "שם הדומיין",
|
"Domain Name": "שם הדומיין",
|
||||||
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",
|
"Domain Verification Code (optional)": "קוד אימות דומיין (אופציונלי)",
|
||||||
@@ -879,4 +893,4 @@
|
|||||||
"results": "תוצאות",
|
"results": "תוצאות",
|
||||||
"selected": "נבחרו",
|
"selected": "נבחרו",
|
||||||
"to": "עד"
|
"to": "עד"
|
||||||
}
|
}
|
||||||
|
|||||||
16
lang/pt.json
16
lang/pt.json
@@ -219,6 +219,20 @@
|
|||||||
"Disk Usage": "Uso de Disco",
|
"Disk Usage": "Uso de Disco",
|
||||||
"Display Name": "Nome de Exibição",
|
"Display Name": "Nome de Exibição",
|
||||||
"Document Root": "Raiz do Documento",
|
"Document Root": "Raiz do Documento",
|
||||||
|
"Documentation": "Documentação",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Encontre respostas na nossa documentação ou fale com o nosso bot de suporte treinado. Explore guias de configuração, etapas de solução de problemas e boas práticas.",
|
||||||
|
"Chat with our AI support bot.": "Converse com nosso bot de suporte com IA.",
|
||||||
|
"GitHub Issues": "Issues do GitHub",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Reporte bugs ou solicite recursos. Inclua passos, logs e capturas de tela para podermos reproduzir rapidamente.",
|
||||||
|
"Open GitHub Issues": "Abrir issues do GitHub",
|
||||||
|
"Paid Support": "Suporte pago",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Obtenha assistência profissional para migrações, ajustes de desempenho e correções prioritárias. Os planos incluem onboarding e suporte dedicado.",
|
||||||
|
"View Support Plans": "Ver planos de suporte",
|
||||||
|
"Response Time": "Tempo de resposta",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Normalmente respondemos em 4-8 horas. Para incidentes críticos, use o Suporte de Emergência para uma resposta mais rápida.",
|
||||||
|
"Emergency Support": "Suporte de emergência",
|
||||||
|
"Open Documentation": "Abrir documentação",
|
||||||
|
"Support Chat": "Chat de suporte",
|
||||||
"Domain": "Domínio",
|
"Domain": "Domínio",
|
||||||
"Domain Name": "Nome do Domínio",
|
"Domain Name": "Nome do Domínio",
|
||||||
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",
|
"Domain Verification Code (optional)": "Código de Verificação do Domínio (opcional)",
|
||||||
@@ -879,4 +893,4 @@
|
|||||||
"results": "resultados",
|
"results": "resultados",
|
||||||
"selected": "selecionado(s)",
|
"selected": "selecionado(s)",
|
||||||
"to": "até"
|
"to": "até"
|
||||||
}
|
}
|
||||||
|
|||||||
16
lang/ru.json
16
lang/ru.json
@@ -220,6 +220,20 @@
|
|||||||
"Disk Usage": "Использование диска",
|
"Disk Usage": "Использование диска",
|
||||||
"Display Name": "Отображаемое имя",
|
"Display Name": "Отображаемое имя",
|
||||||
"Document Root": "Корневая директория",
|
"Document Root": "Корневая директория",
|
||||||
|
"Documentation": "Документация",
|
||||||
|
"Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.": "Найдите ответы в нашей документации или поговорите с нашим обученным ботом поддержки. Изучите руководства по настройке, шаги по устранению неполадок и лучшие практики.",
|
||||||
|
"Chat with our AI support bot.": "Чат с нашим AI-ботом поддержки.",
|
||||||
|
"GitHub Issues": "GitHub Issues",
|
||||||
|
"Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.": "Сообщайте о багах или запрашивайте функции. Укажите шаги, логи и скриншоты, чтобы мы могли быстро воспроизвести.",
|
||||||
|
"Open GitHub Issues": "Открыть GitHub Issues",
|
||||||
|
"Paid Support": "Платная поддержка",
|
||||||
|
"Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.": "Профессиональная помощь по миграциям, оптимизации производительности и приоритетным исправлениям. Планы включают онбординг и выделенную поддержку.",
|
||||||
|
"View Support Plans": "Посмотреть планы поддержки",
|
||||||
|
"Response Time": "Время ответа",
|
||||||
|
"We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.": "Обычно отвечаем в течение 4–8 часов. Для критических инцидентов используйте экстренную поддержку для более быстрого ответа.",
|
||||||
|
"Emergency Support": "Экстренная поддержка",
|
||||||
|
"Open Documentation": "Открыть документацию",
|
||||||
|
"Support Chat": "Чат поддержки",
|
||||||
"Domain": "Домен",
|
"Domain": "Домен",
|
||||||
"Domain Name": "Имя домена",
|
"Domain Name": "Имя домена",
|
||||||
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",
|
"Domain Verification Code (optional)": "Код подтверждения домена (необязательно)",
|
||||||
@@ -886,4 +900,4 @@
|
|||||||
"results": "результатов",
|
"results": "результатов",
|
||||||
"selected": "выбрано",
|
"selected": "выбрано",
|
||||||
"to": "до"
|
"to": "до"
|
||||||
}
|
}
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -12,7 +12,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.13.5",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
@@ -1181,13 +1181,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.13.2",
|
"version": "1.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.11",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.5",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.13.5",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"laravel-vite-plugin": "^2.0.0",
|
"laravel-vite-plugin": "^2.0.0",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
[x-cloak] {
|
[x-cloak] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountConfigTable::class, [
|
||||||
|
'importId' => $this->importId,
|
||||||
|
], key('directadmin-account-config-table-' . ($this->importId ?? 'new')))
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@livewire(\App\Filament\Admin\Widgets\DirectAdminAccountsTable::class, [
|
||||||
|
'importId' => $this->importId,
|
||||||
|
], key('directadmin-accounts-table-' . ($this->importId ?? 'new')))
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@livewire(\App\Filament\Admin\Widgets\DirectAdminMigrationStatusTable::class, [
|
||||||
|
'importId' => $this->importId,
|
||||||
|
], key('directadmin-migration-status-table-' . ($this->importId ?? 'new')))
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->migrationForm }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@livewire(\App\Filament\Admin\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))
|
||||||
|
|
||||||
78
resources/views/filament/admin/pages/support.blade.php
Normal file
78
resources/views/filament/admin/pages/support.blade.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-book-open"
|
||||||
|
icon-color="primary"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/docs/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
>
|
||||||
|
{{ __('Open Documentation') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-bug-ant"
|
||||||
|
icon-color="warning"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://github.com/shukiv/jabali-panel/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
{{ __('Open GitHub Issues') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-lifebuoy"
|
||||||
|
icon-color="primary"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/support/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
>
|
||||||
|
{{ __('View Support Plans') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-clock"
|
||||||
|
icon-color="gray"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/emergency/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
color="warning"
|
||||||
|
>
|
||||||
|
{{ __('Emergency Support') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@livewire(\App\Filament\Jabali\Widgets\DirectAdminMigrationStatusTable::class, [
|
||||||
|
'importId' => $this->importId,
|
||||||
|
], key('directadmin-self-migration-status-table-' . ($this->importId ?? 'new')))
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->migrationForm }}
|
||||||
|
|
||||||
|
<x-filament-actions::modals />
|
||||||
|
</x-filament-panels::page>
|
||||||
|
|
||||||
@@ -1,4 +1,47 @@
|
|||||||
<x-filament-panels::page>
|
<x-filament-panels::page>
|
||||||
|
<style>
|
||||||
|
/* Compact spacing for File Manager rows */
|
||||||
|
#file-dropzone .fi-ta-text:not(.fi-inline) {
|
||||||
|
padding-top: 0.2rem !important;
|
||||||
|
padding-bottom: 0.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone .fi-ta-text-item {
|
||||||
|
line-height: 1.05rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone .fi-ta-record-content-ctn {
|
||||||
|
gap: 0.25rem !important;
|
||||||
|
padding-top: 0.25rem !important;
|
||||||
|
padding-bottom: 0.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone .fi-ta-record-checkbox {
|
||||||
|
margin-top: 0.2rem !important;
|
||||||
|
margin-bottom: 0.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone td.fi-ta-cell.fi-ta-selection-cell,
|
||||||
|
#file-dropzone td.fi-ta-cell.fi-ta-group-selection-cell {
|
||||||
|
padding-top: 0.2rem !important;
|
||||||
|
padding-bottom: 0.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone td.fi-ta-cell:has(.fi-ta-actions) {
|
||||||
|
padding-top: 0.2rem !important;
|
||||||
|
padding-bottom: 0.2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone .fi-ta-actions {
|
||||||
|
gap: 0.35rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-dropzone .fi-ta-actions .fi-btn,
|
||||||
|
#file-dropzone .fi-ta-actions .fi-icon-btn {
|
||||||
|
min-height: 1.65rem !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
{{-- Warning Banner --}}
|
{{-- Warning Banner --}}
|
||||||
<x-filament::section
|
<x-filament::section
|
||||||
icon="heroicon-o-exclamation-triangle"
|
icon="heroicon-o-exclamation-triangle"
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
<x-filament::section class="mt-4" icon="heroicon-o-clipboard-document-list">
|
|
||||||
<x-slot name="heading">{{ __('Activity Log') }}</x-slot>
|
|
||||||
<x-slot name="description">{{ __('Recent actions performed in your account.') }}</x-slot>
|
|
||||||
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Time') }}</th>
|
|
||||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Category') }}</th>
|
|
||||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Action') }}</th>
|
|
||||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('Description') }}</th>
|
|
||||||
<th class="px-4 py-3 text-left fi-section-header-heading">{{ __('IP') }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
@forelse($this->getActivityLogs() as $log)
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->created_at?->format('Y-m-d H:i') }}</td>
|
|
||||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->category }}</td>
|
|
||||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->action }}</td>
|
|
||||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->description }}</td>
|
|
||||||
<td class="px-4 py-3 fi-section-header-description">{{ $log->ip_address }}</td>
|
|
||||||
</tr>
|
|
||||||
@empty
|
|
||||||
<tr>
|
|
||||||
<td colspan="5" class="px-4 py-6 text-center fi-section-header-description">
|
|
||||||
{{ __('No activity recorded yet.') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@endforelse
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@livewire(\App\Filament\Jabali\Pages\CpanelMigration::class, [], key('migration-cpanel'))
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@livewire(\App\Filament\Jabali\Pages\DirectAdminMigration::class, [], key('migration-directadmin'))
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->migrationForm }}
|
||||||
|
</x-filament-panels::page>
|
||||||
|
|
||||||
78
resources/views/filament/jabali/pages/support.blade.php
Normal file
78
resources/views/filament/jabali/pages/support.blade.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<div class="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-book-open"
|
||||||
|
icon-color="primary"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Documentation') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Find answers in our docs or talk with our trainned support bot. Explore setup guides, troubleshooting steps, and best practices.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/docs/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
>
|
||||||
|
{{ __('Open Documentation') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-bug-ant"
|
||||||
|
icon-color="warning"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('GitHub Issues') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Report bugs or request features. Include steps, logs, and screenshots so we can reproduce quickly.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://github.com/shukiv/jabali-panel/issues"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
color="gray"
|
||||||
|
>
|
||||||
|
{{ __('Open GitHub Issues') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-lifebuoy"
|
||||||
|
icon-color="primary"
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Paid Support') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('Get professional assistance for migrations, performance tuning, and priority fixes. Plans include onboarding and dedicated support.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/support/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
>
|
||||||
|
{{ __('View Support Plans') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
<x-filament::section
|
||||||
|
icon="heroicon-o-clock"
|
||||||
|
icon-color="gray"
|
||||||
|
compact
|
||||||
|
>
|
||||||
|
<x-slot name="heading">{{ __('Emergency Support') }}</x-slot>
|
||||||
|
<x-slot name="description">{{ __('We typically respond within 4-8 hours. For critical incidents, use Emergency Support for faster response.') }}</x-slot>
|
||||||
|
|
||||||
|
<x-filament::button
|
||||||
|
tag="a"
|
||||||
|
href="https://jabali-panel.com/emergency/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
icon="heroicon-o-arrow-top-right-on-square"
|
||||||
|
color="warning"
|
||||||
|
>
|
||||||
|
{{ __('Emergency Support') }}
|
||||||
|
</x-filament::button>
|
||||||
|
</x-filament::section>
|
||||||
|
</div>
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -1,33 +1,53 @@
|
|||||||
@php
|
@php
|
||||||
$jabaliVersion = '1.0.1';
|
$jabaliVersion = 'unknown';
|
||||||
$versionFile = base_path('VERSION');
|
$versionFile = base_path('VERSION');
|
||||||
if (file_exists($versionFile)) {
|
if (file_exists($versionFile)) {
|
||||||
$content = file_get_contents($versionFile);
|
$content = file_get_contents($versionFile);
|
||||||
if (preg_match('/VERSION=(.+)/', $content, $matches)) {
|
if (preg_match('/^VERSION=(.+)$/m', $content, $matches)) {
|
||||||
$jabaliVersion = trim($matches[1]);
|
$jabaliVersion = trim($matches[1]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@endphp
|
@endphp
|
||||||
<style>
|
|
||||||
.dark .jabali-footer-logo { filter: invert(1) brightness(2); }
|
<footer class="mt-auto border-t border-gray-200/20 px-6 py-5 dark:border-white/10">
|
||||||
</style>
|
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div style="border-top: 1px solid rgba(128,128,128,0.1); padding: 20px 24px; margin-top: auto;">
|
<div class="flex items-center gap-3">
|
||||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px;">
|
<img
|
||||||
<div style="display: flex; align-items: center; gap: 12px;">
|
src="{{ asset('images/jabali_logo.svg') }}"
|
||||||
<img src="{{ asset('images/jabali_logo.svg') }}" alt="Jabali" style="height: 32px; width: 32px;" class="jabali-footer-logo">
|
alt="{{ __('Jabali') }}"
|
||||||
<div>
|
class="h-8 w-8 dark:filter dark:invert dark:brightness-200"
|
||||||
<div style="font-weight: 600; font-size: 15px;" class="text-gray-700 dark:text-gray-200">Jabali Panel</div>
|
>
|
||||||
<div style="font-size: 12px;" class="text-gray-500 dark:text-gray-400">Web Hosting Control Panel</div>
|
|
||||||
|
<div class="leading-tight">
|
||||||
|
<div class="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
{{ __('Jabali Panel') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ __('Web Hosting Control Panel') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
<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>
|
<x-filament::link
|
||||||
GitHub
|
tag="a"
|
||||||
</a>
|
href="https://github.com/shukiv/jabali-panel"
|
||||||
<span style="color: rgba(128,128,128,0.3);">•</span>
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
color="gray"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ __('GitHub') }}
|
||||||
|
</x-filament::link>
|
||||||
|
|
||||||
|
<span class="text-gray-400/50 dark:text-white/20">•</span>
|
||||||
|
|
||||||
<span>© {{ date('Y') }} Jabali</span>
|
<span>© {{ date('Y') }} Jabali</span>
|
||||||
<span style="background: linear-gradient(135deg, #3b82f6, #8b5cf6); color: white; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 600;">v{{ $jabaliVersion }}</span>
|
|
||||||
|
<x-filament::badge size="sm" color="gray">
|
||||||
|
v{{ $jabaliVersion }}
|
||||||
|
</x-filament::badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</footer>
|
||||||
|
|||||||
@@ -25,13 +25,26 @@ Route::post('/phpmyadmin/verify-token', function (Request $request) {
|
|||||||
Cache::forget('phpmyadmin_token_'.$token);
|
Cache::forget('phpmyadmin_token_'.$token);
|
||||||
|
|
||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
});
|
})->middleware('throttle:internal-api');
|
||||||
|
|
||||||
|
$allowInternalRequest = static function (Request $request): bool {
|
||||||
|
$remoteAddr = (string) $request->server('REMOTE_ADDR', $request->ip());
|
||||||
|
$isLocalRequest = in_array($remoteAddr, ['127.0.0.1', '::1'], true);
|
||||||
|
|
||||||
|
$configuredToken = trim((string) config('app.internal_api_token', ''));
|
||||||
|
$providedToken = trim((string) (
|
||||||
|
$request->header('X-Jabali-Internal-Token')
|
||||||
|
?? $request->input('internal_token')
|
||||||
|
?? ''
|
||||||
|
));
|
||||||
|
$hasValidToken = $configuredToken !== '' && $providedToken !== '' && hash_equals($configuredToken, $providedToken);
|
||||||
|
|
||||||
|
return $isLocalRequest || $hasValidToken;
|
||||||
|
};
|
||||||
|
|
||||||
// Internal API for jabali-cache WordPress plugin
|
// Internal API for jabali-cache WordPress plugin
|
||||||
Route::post('/internal/page-cache', function (Request $request) {
|
Route::post('/internal/page-cache', function (Request $request) use ($allowInternalRequest) {
|
||||||
// Only allow requests from localhost
|
if (! $allowInternalRequest($request)) {
|
||||||
$clientIp = $request->ip();
|
|
||||||
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
|
|
||||||
return response()->json(['error' => 'Forbidden'], 403);
|
return response()->json(['error' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +81,7 @@ Route::post('/internal/page-cache', function (Request $request) {
|
|||||||
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
|
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
|
||||||
$authKey = $matches[1];
|
$authKey = $matches[1];
|
||||||
$expectedSecret = substr(md5($authKey), 0, 32);
|
$expectedSecret = substr(md5($authKey), 0, 32);
|
||||||
if ($secret !== $expectedSecret) {
|
if (! hash_equals($expectedSecret, $secret)) {
|
||||||
return response()->json(['error' => 'Invalid secret'], 401);
|
return response()->json(['error' => 'Invalid secret'], 401);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -94,13 +107,11 @@ Route::post('/internal/page-cache', function (Request $request) {
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json(['error' => $e->getMessage()], 500);
|
return response()->json(['error' => $e->getMessage()], 500);
|
||||||
}
|
}
|
||||||
});
|
})->middleware('throttle:internal-api');
|
||||||
|
|
||||||
// Internal API for smart page cache purging (called by jabali-cache WordPress plugin)
|
// Internal API for smart page cache purging (called by jabali-cache WordPress plugin)
|
||||||
Route::post('/internal/page-cache-purge', function (Request $request) {
|
Route::post('/internal/page-cache-purge', function (Request $request) use ($allowInternalRequest) {
|
||||||
// Only allow requests from localhost
|
if (! $allowInternalRequest($request)) {
|
||||||
$clientIp = $request->ip();
|
|
||||||
if (! in_array($clientIp, ['127.0.0.1', '::1', 'localhost'])) {
|
|
||||||
return response()->json(['error' => 'Forbidden'], 403);
|
return response()->json(['error' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +148,7 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
|
|||||||
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
|
if (preg_match("/define\s*\(\s*['\"]AUTH_KEY['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $wpConfig, $matches)) {
|
||||||
$authKey = $matches[1];
|
$authKey = $matches[1];
|
||||||
$expectedSecret = substr(md5($authKey), 0, 32);
|
$expectedSecret = substr(md5($authKey), 0, 32);
|
||||||
if ($secret !== $expectedSecret) {
|
if (! hash_equals($expectedSecret, $secret)) {
|
||||||
return response()->json(['error' => 'Invalid secret'], 401);
|
return response()->json(['error' => 'Invalid secret'], 401);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -164,9 +175,12 @@ Route::post('/internal/page-cache-purge', function (Request $request) {
|
|||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return response()->json(['error' => $e->getMessage()], 500);
|
return response()->json(['error' => $e->getMessage()], 500);
|
||||||
}
|
}
|
||||||
});
|
})->middleware('throttle:internal-api');
|
||||||
|
|
||||||
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class);
|
Route::post('/webhooks/git/{deployment}', GitWebhookController::class)
|
||||||
|
->middleware('throttle:git-webhooks');
|
||||||
|
Route::post('/webhooks/git/{deployment}/{token}', GitWebhookController::class)
|
||||||
|
->middleware('throttle:git-webhooks');
|
||||||
|
|
||||||
Route::middleware(['auth:sanctum', 'abilities:automation'])
|
Route::middleware(['auth:sanctum', 'abilities:automation'])
|
||||||
->prefix('automation')
|
->prefix('automation')
|
||||||
|
|||||||
493
scripts/deploy.sh
Executable file
493
scripts/deploy.sh
Executable file
@@ -0,0 +1,493 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config.toml}"
|
||||||
|
|
||||||
|
# Capture env overrides before we assign defaults so config.toml can sit between
|
||||||
|
# defaults and environment: CLI > env > config > defaults.
|
||||||
|
ENV_DEPLOY_HOST="${DEPLOY_HOST-}"
|
||||||
|
ENV_DEPLOY_USER="${DEPLOY_USER-}"
|
||||||
|
ENV_DEPLOY_PATH="${DEPLOY_PATH-}"
|
||||||
|
ENV_WWW_USER="${WWW_USER-}"
|
||||||
|
ENV_NPM_CACHE_DIR="${NPM_CACHE_DIR-}"
|
||||||
|
ENV_GITEA_REMOTE="${GITEA_REMOTE-}"
|
||||||
|
ENV_GITEA_URL="${GITEA_URL-}"
|
||||||
|
ENV_GITHUB_REMOTE="${GITHUB_REMOTE-}"
|
||||||
|
ENV_GITHUB_URL="${GITHUB_URL-}"
|
||||||
|
ENV_PUSH_BRANCH="${PUSH_BRANCH-}"
|
||||||
|
|
||||||
|
DEPLOY_HOST="192.168.100.50"
|
||||||
|
DEPLOY_USER="root"
|
||||||
|
DEPLOY_PATH="/var/www/jabali"
|
||||||
|
WWW_USER="www-data"
|
||||||
|
NPM_CACHE_DIR=""
|
||||||
|
GITEA_REMOTE="gitea"
|
||||||
|
GITEA_URL=""
|
||||||
|
GITHUB_REMOTE="origin"
|
||||||
|
GITHUB_URL=""
|
||||||
|
PUSH_BRANCH=""
|
||||||
|
|
||||||
|
trim_ws() {
|
||||||
|
local s="${1:-}"
|
||||||
|
s="$(echo "$s" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
toml_unquote() {
|
||||||
|
local v
|
||||||
|
v="$(trim_ws "${1:-}")"
|
||||||
|
|
||||||
|
# Only accept simple double-quoted strings, booleans, or integers.
|
||||||
|
if [[ ${#v} -ge 2 && "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then
|
||||||
|
printf '%s' "${v:1:${#v}-2}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$v" =~ ^(true|false)$ ]]; then
|
||||||
|
printf '%s' "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$v" =~ ^-?[0-9]+$ ]]; then
|
||||||
|
printf '%s' "$v"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
load_config_toml() {
|
||||||
|
local file section line key raw value
|
||||||
|
file="$1"
|
||||||
|
section=""
|
||||||
|
|
||||||
|
[[ -f "$file" ]] || return 0
|
||||||
|
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
# Strip comments and whitespace.
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="$(trim_ws "$line")"
|
||||||
|
[[ -z "$line" ]] && continue
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^\[([A-Za-z0-9_.-]+)\]$ ]]; then
|
||||||
|
section="${BASH_REMATCH[1]}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$section" == "deploy" ]] || continue
|
||||||
|
|
||||||
|
if [[ "$line" =~ ^([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
|
||||||
|
key="${BASH_REMATCH[1]}"
|
||||||
|
raw="${BASH_REMATCH[2]}"
|
||||||
|
value=""
|
||||||
|
|
||||||
|
if ! value="$(toml_unquote "$raw")"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$key" in
|
||||||
|
host) DEPLOY_HOST="$value" ;;
|
||||||
|
user) DEPLOY_USER="$value" ;;
|
||||||
|
path) DEPLOY_PATH="$value" ;;
|
||||||
|
www_user) WWW_USER="$value" ;;
|
||||||
|
npm_cache_dir) NPM_CACHE_DIR="$value" ;;
|
||||||
|
gitea_remote) GITEA_REMOTE="$value" ;;
|
||||||
|
gitea_url) GITEA_URL="$value" ;;
|
||||||
|
github_remote) GITHUB_REMOTE="$value" ;;
|
||||||
|
github_url) GITHUB_URL="$value" ;;
|
||||||
|
push_branch) PUSH_BRANCH="$value" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_config_toml "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# Apply environment overrides on top of config.
|
||||||
|
if [[ -n "${ENV_DEPLOY_HOST:-}" ]]; then DEPLOY_HOST="$ENV_DEPLOY_HOST"; fi
|
||||||
|
if [[ -n "${ENV_DEPLOY_USER:-}" ]]; then DEPLOY_USER="$ENV_DEPLOY_USER"; fi
|
||||||
|
if [[ -n "${ENV_DEPLOY_PATH:-}" ]]; then DEPLOY_PATH="$ENV_DEPLOY_PATH"; fi
|
||||||
|
if [[ -n "${ENV_WWW_USER:-}" ]]; then WWW_USER="$ENV_WWW_USER"; fi
|
||||||
|
if [[ -n "${ENV_NPM_CACHE_DIR:-}" ]]; then NPM_CACHE_DIR="$ENV_NPM_CACHE_DIR"; fi
|
||||||
|
if [[ -n "${ENV_GITEA_REMOTE:-}" ]]; then GITEA_REMOTE="$ENV_GITEA_REMOTE"; fi
|
||||||
|
if [[ -n "${ENV_GITEA_URL:-}" ]]; then GITEA_URL="$ENV_GITEA_URL"; fi
|
||||||
|
if [[ -n "${ENV_GITHUB_REMOTE:-}" ]]; then GITHUB_REMOTE="$ENV_GITHUB_REMOTE"; fi
|
||||||
|
if [[ -n "${ENV_GITHUB_URL:-}" ]]; then GITHUB_URL="$ENV_GITHUB_URL"; fi
|
||||||
|
if [[ -n "${ENV_PUSH_BRANCH:-}" ]]; then PUSH_BRANCH="$ENV_PUSH_BRANCH"; fi
|
||||||
|
|
||||||
|
SKIP_SYNC=0
|
||||||
|
SKIP_COMPOSER=0
|
||||||
|
SKIP_NPM=0
|
||||||
|
SKIP_MIGRATE=0
|
||||||
|
SKIP_CACHE=0
|
||||||
|
SKIP_AGENT_RESTART=0
|
||||||
|
DELETE_REMOTE=0
|
||||||
|
DRY_RUN=0
|
||||||
|
SKIP_PUSH=0
|
||||||
|
PUSH_GITEA=1
|
||||||
|
PUSH_GITHUB=1
|
||||||
|
SET_VERSION=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: scripts/deploy.sh [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--host HOST Remote host (default: 192.168.100.50)
|
||||||
|
--user USER SSH user (default: root)
|
||||||
|
--path PATH Remote path (default: /var/www/jabali)
|
||||||
|
--www-user USER Remote runtime user (default: www-data)
|
||||||
|
--skip-sync Skip rsync sync step
|
||||||
|
--skip-composer Skip composer install
|
||||||
|
--skip-npm Skip npm install/build
|
||||||
|
--skip-migrate Skip php artisan migrate
|
||||||
|
--skip-cache Skip cache clear/rebuild
|
||||||
|
--skip-agent-restart Skip restarting jabali-agent service
|
||||||
|
--delete Pass --delete to rsync (dangerous)
|
||||||
|
--dry-run Dry-run rsync only
|
||||||
|
--skip-push Skip all git push operations
|
||||||
|
--push-gitea Push current branch to Gitea from deploy server (default: on)
|
||||||
|
--no-push-gitea Disable Gitea push
|
||||||
|
--gitea-remote NAME Gitea git remote name (default: gitea)
|
||||||
|
--gitea-url URL Push to this URL instead of a named remote
|
||||||
|
--push-github Push current branch to GitHub from deploy server (default: on)
|
||||||
|
--no-push-github Disable GitHub push
|
||||||
|
--github-remote NAME GitHub git remote name (default: origin)
|
||||||
|
--github-url URL Push to this URL instead of a named remote
|
||||||
|
--version VALUE Set VERSION to a specific value before remote push
|
||||||
|
-h, --help Show this help
|
||||||
|
|
||||||
|
Environment overrides:
|
||||||
|
CONFIG_FILE points to a TOML file (default: ./config.toml). The script reads [deploy] keys.
|
||||||
|
CONFIG_FILE, DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--host)
|
||||||
|
DEPLOY_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--user)
|
||||||
|
DEPLOY_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--path)
|
||||||
|
DEPLOY_PATH="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--www-user)
|
||||||
|
WWW_USER="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--skip-sync)
|
||||||
|
SKIP_SYNC=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-composer)
|
||||||
|
SKIP_COMPOSER=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-npm)
|
||||||
|
SKIP_NPM=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-migrate)
|
||||||
|
SKIP_MIGRATE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-cache)
|
||||||
|
SKIP_CACHE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-agent-restart)
|
||||||
|
SKIP_AGENT_RESTART=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--delete)
|
||||||
|
DELETE_REMOTE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
DRY_RUN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-push)
|
||||||
|
SKIP_PUSH=1
|
||||||
|
PUSH_GITEA=0
|
||||||
|
PUSH_GITHUB=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--push-gitea)
|
||||||
|
PUSH_GITEA=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-push-gitea)
|
||||||
|
PUSH_GITEA=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--gitea-remote)
|
||||||
|
GITEA_REMOTE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--gitea-url)
|
||||||
|
GITEA_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--push-github)
|
||||||
|
PUSH_GITHUB=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-push-github)
|
||||||
|
PUSH_GITHUB=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--github-remote)
|
||||||
|
GITHUB_REMOTE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--github-url)
|
||||||
|
GITHUB_URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--version)
|
||||||
|
SET_VERSION="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
|
||||||
|
|
||||||
|
ensure_remote_git_clean() {
|
||||||
|
local status_output
|
||||||
|
status_output="$(remote_run "if [[ ! -d \"$DEPLOY_PATH/.git\" ]]; then echo '__NO_GIT__'; exit 0; fi; cd \"$DEPLOY_PATH\" && git status --porcelain")"
|
||||||
|
|
||||||
|
if [[ "$status_output" == "__NO_GIT__" ]]; then
|
||||||
|
echo "Remote path is not a git repository: $DEPLOY_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$status_output" ]]; then
|
||||||
|
echo "Remote git worktree is dirty at $DEPLOY_PATH. Commit or stash remote changes first."
|
||||||
|
echo "$status_output"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_commit_and_push() {
|
||||||
|
local local_head push_branch
|
||||||
|
local_head="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
|
||||||
|
if [[ -n "$PUSH_BRANCH" ]]; then
|
||||||
|
push_branch="$PUSH_BRANCH"
|
||||||
|
else
|
||||||
|
push_branch="$(remote_run "cd \"$DEPLOY_PATH\" && git rev-parse --abbrev-ref HEAD")"
|
||||||
|
if [[ -z "$push_branch" || "$push_branch" == "HEAD" ]]; then
|
||||||
|
push_branch="main"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -o StrictHostKeyChecking=no "$REMOTE" \
|
||||||
|
DEPLOY_PATH="$DEPLOY_PATH" \
|
||||||
|
PUSH_BRANCH="$push_branch" \
|
||||||
|
PUSH_GITEA="$PUSH_GITEA" \
|
||||||
|
PUSH_GITHUB="$PUSH_GITHUB" \
|
||||||
|
GITEA_REMOTE="$GITEA_REMOTE" \
|
||||||
|
GITEA_URL="$GITEA_URL" \
|
||||||
|
GITHUB_REMOTE="$GITHUB_REMOTE" \
|
||||||
|
GITHUB_URL="$GITHUB_URL" \
|
||||||
|
SET_VERSION="$SET_VERSION" \
|
||||||
|
LOCAL_HEAD="$local_head" \
|
||||||
|
bash -s <<'EOF'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$DEPLOY_PATH"
|
||||||
|
|
||||||
|
if [[ ! -d .git ]]; then
|
||||||
|
echo "Remote path is not a git repository: $DEPLOY_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config --global --add safe.directory "$DEPLOY_PATH" >/dev/null 2>&1 || true
|
||||||
|
if ! git config user.name >/dev/null; then
|
||||||
|
git config user.name "Jabali Deploy"
|
||||||
|
fi
|
||||||
|
if ! git config user.email >/dev/null; then
|
||||||
|
git config user.email "root@$(hostname -f 2>/dev/null || hostname)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
current="$(sed -n 's/^VERSION=//p' VERSION || true)"
|
||||||
|
if [[ -z "$current" ]]; then
|
||||||
|
echo "VERSION file missing or invalid on remote." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${SET_VERSION:-}" ]]; then
|
||||||
|
new="$SET_VERSION"
|
||||||
|
else
|
||||||
|
if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
|
||||||
|
base="${BASH_REMATCH[1]}"
|
||||||
|
num="${BASH_REMATCH[2]}"
|
||||||
|
if [[ -z "$num" ]]; then
|
||||||
|
num=1
|
||||||
|
else
|
||||||
|
num=$((num + 1))
|
||||||
|
fi
|
||||||
|
new="${base}${num}"
|
||||||
|
elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
|
||||||
|
new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
|
||||||
|
else
|
||||||
|
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$new" == "$current" ]]; then
|
||||||
|
echo "VERSION is already '$current'. Use --version to set a new value." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'VERSION=%s\n' "$new" > VERSION
|
||||||
|
sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "No changes detected after sync/version bump; skipping commit."
|
||||||
|
else
|
||||||
|
git commit -m "Deploy sync from ${LOCAL_HEAD} (v${new})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PUSH_GITEA" -eq 1 ]]; then
|
||||||
|
if [[ -n "$GITEA_URL" ]]; then
|
||||||
|
git push "$GITEA_URL" "$PUSH_BRANCH"
|
||||||
|
else
|
||||||
|
git push "$GITEA_REMOTE" "$PUSH_BRANCH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
|
||||||
|
if [[ -n "$GITHUB_URL" ]]; then
|
||||||
|
git push "$GITHUB_URL" "$PUSH_BRANCH"
|
||||||
|
else
|
||||||
|
git push "$GITHUB_REMOTE" "$PUSH_BRANCH"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
rsync_project() {
|
||||||
|
local -a rsync_opts
|
||||||
|
rsync_opts=(-az --info=progress2)
|
||||||
|
if [[ "$DELETE_REMOTE" -eq 1 ]]; then
|
||||||
|
rsync_opts+=(--delete)
|
||||||
|
fi
|
||||||
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
|
rsync_opts+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
rsync "${rsync_opts[@]}" \
|
||||||
|
--exclude ".git/" \
|
||||||
|
--exclude "node_modules/" \
|
||||||
|
--exclude "vendor/" \
|
||||||
|
--exclude "storage/" \
|
||||||
|
--exclude "bootstrap/cache/" \
|
||||||
|
--exclude "public/build/" \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude ".env.*" \
|
||||||
|
--exclude "database/*.sqlite" \
|
||||||
|
--exclude "database/*.sqlite-wal" \
|
||||||
|
--exclude "database/*.sqlite-shm" \
|
||||||
|
"$ROOT_DIR/" \
|
||||||
|
"${REMOTE}:${DEPLOY_PATH}/"
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_run() {
|
||||||
|
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc '$1'"
|
||||||
|
}
|
||||||
|
|
||||||
|
remote_run_www() {
|
||||||
|
ssh -o StrictHostKeyChecking=no "$REMOTE" "bash -lc 'cd \"$DEPLOY_PATH\" && sudo -u \"$WWW_USER\" -H bash -lc \"$1\"'"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_remote_permissions() {
|
||||||
|
local parent_dir
|
||||||
|
parent_dir="$(dirname "$DEPLOY_PATH")"
|
||||||
|
|
||||||
|
if [[ -z "$NPM_CACHE_DIR" ]]; then
|
||||||
|
NPM_CACHE_DIR="${parent_dir}/.npm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
remote_run "mkdir -p \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
|
||||||
|
remote_run "chown -R \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/storage\" \"$DEPLOY_PATH/bootstrap/cache\" \"$DEPLOY_PATH/public\" \"$DEPLOY_PATH/public/build\" \"$DEPLOY_PATH/node_modules\" \"$DEPLOY_PATH/database\" \"$NPM_CACHE_DIR\""
|
||||||
|
remote_run "if [[ -f \"$DEPLOY_PATH/auth.json\" ]]; then chown \"$WWW_USER\":\"$WWW_USER\" \"$DEPLOY_PATH/auth.json\" && chmod 600 \"$DEPLOY_PATH/auth.json\"; fi"
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 0 && "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
|
||||||
|
echo "Validating remote git worktree..."
|
||||||
|
ensure_remote_git_clean
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_SYNC" -eq 0 ]]; then
|
||||||
|
echo "Syncing project files..."
|
||||||
|
rsync_project
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$DRY_RUN" -eq 1 ]]; then
|
||||||
|
echo "Dry run complete. No remote commands executed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
|
||||||
|
echo "Committing and pushing from ${REMOTE}..."
|
||||||
|
remote_commit_and_push
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Ensuring remote permissions..."
|
||||||
|
ensure_remote_permissions
|
||||||
|
|
||||||
|
if [[ "$SKIP_COMPOSER" -eq 0 ]]; then
|
||||||
|
echo "Installing composer dependencies..."
|
||||||
|
remote_run_www "composer install --no-interaction --prefer-dist --optimize-autoloader"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_NPM" -eq 0 ]]; then
|
||||||
|
echo "Building frontend assets..."
|
||||||
|
remote_run_www "npm ci"
|
||||||
|
remote_run_www "npm run build"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_MIGRATE" -eq 0 ]]; then
|
||||||
|
echo "Running migrations..."
|
||||||
|
remote_run_www "php artisan migrate --force"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_CACHE" -eq 0 ]]; then
|
||||||
|
echo "Refreshing caches..."
|
||||||
|
remote_run_www "php artisan optimize:clear"
|
||||||
|
remote_run_www "php artisan config:cache"
|
||||||
|
remote_run_www "php artisan route:cache"
|
||||||
|
remote_run_www "php artisan view:cache"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_AGENT_RESTART" -eq 0 ]]; then
|
||||||
|
echo "Restarting jabali-agent service..."
|
||||||
|
remote_run "if systemctl list-unit-files jabali-agent.service --no-legend 2>/dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Deploy complete."
|
||||||
98
tests/Feature/ApiSecurityHardeningTest.php
Normal file
98
tests/Feature/ApiSecurityHardeningTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
43
tests/Feature/Filament/SupportPagesTest.php
Normal file
43
tests/Feature/Filament/SupportPagesTest.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Filament;
|
||||||
|
|
||||||
|
use App\Filament\Admin\Pages\Support as AdminSupport;
|
||||||
|
use App\Filament\Jabali\Pages\Support as UserSupport;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class SupportPagesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_admin_support_page_renders_support_links(): void
|
||||||
|
{
|
||||||
|
$admin = User::factory()->admin()->create();
|
||||||
|
|
||||||
|
$this->actingAs($admin);
|
||||||
|
|
||||||
|
Livewire::test(AdminSupport::class)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSee('Open Documentation')
|
||||||
|
->assertSee('GitHub Issues')
|
||||||
|
->assertSee('Paid Support');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_user_support_page_renders_support_links(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create();
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
Livewire::test(UserSupport::class)
|
||||||
|
->assertStatus(200)
|
||||||
|
->assertSee('Open Documentation')
|
||||||
|
->assertSee('GitHub Issues')
|
||||||
|
->assertSee('Paid Support');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class ServerChartsWidgetTest extends TestCase
|
|||||||
$this->assertArrayHasKey('disk', $history);
|
$this->assertArrayHasKey('disk', $history);
|
||||||
$this->assertArrayHasKey('/', $history['disk']);
|
$this->assertArrayHasKey('/', $history['disk']);
|
||||||
$this->assertArrayHasKey('/boot', $history['disk']);
|
$this->assertArrayHasKey('/boot', $history['disk']);
|
||||||
$this->assertCount(5, $history['labels']);
|
$this->assertCount(30, $history['labels']);
|
||||||
$this->assertCount(count($history['labels']), $history['swap']);
|
$this->assertCount(count($history['labels']), $history['swap']);
|
||||||
$this->assertCount(count($history['labels']), $history['iowait']);
|
$this->assertCount(count($history['labels']), $history['iowait']);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user