Compare commits

...

41 Commits

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 16:50:49 +00:00
root
8acc55a799 Refine database warning banner and docs 2026-02-06 18:46:16 +00:00
root
a5742a3156 Add confirmations for service disabling 2026-02-06 17:00:13 +00:00
root
66c4be426d Add backups, migration, and DNS/mail guides 2026-02-06 02:47:44 +00:00
root
adc073b751 Add ops, security, and troubleshooting docs 2026-02-06 02:47:44 +00:00
root
e439204891 Add installation and uninstall docs 2026-02-06 02:47:44 +00:00
root
fd3be5b1cd Allow jabali.lan for docs dev server 2026-02-06 02:47:44 +00:00
root
758412168f Allow all Vite hosts for dev 2026-02-06 02:47:43 +00:00
root
66a1eaba0a Allow jabali.lan in Vite dev server 2026-02-06 02:47:43 +00:00
root
b7c0419e05 Update panels, docs, and screenshots 2026-02-06 02:47:43 +00:00
codex
6c84704476 Revert "Update demo password"
This reverts commit e977d66335.
2026-02-06 02:47:43 +00:00
codex
8df76cdaae Update demo password 2026-02-06 02:47:43 +00:00
codex
d8424ad483 Make website/demo links clickable 2026-02-06 02:47:43 +00:00
codex
a0048109ce Update README demo links and bump version 2026-02-06 02:47:43 +00:00
root
be34afe2c8 Update README, docs, and version 2026-02-06 02:10:23 +00:00
424 changed files with 19714 additions and 2080 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
node_modules
storage/logs
storage/framework/cache
storage/framework/sessions
storage/framework/views
storage/app/private
storage/app/private/*
storage/app/private/livewire-tmp
.env
.env.*
!.env.example

View File

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

3
.gitignore vendored
View File

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

6
.stylelintignore Normal file
View File

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

18
.stylelintrc.json Normal file
View File

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

View File

@@ -58,6 +58,7 @@ php artisan route:cache # Cache routes
## Git Workflow
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured).
### Version Numbers
@@ -326,6 +327,18 @@ dns.get_ds_records - Get DS records for registrar
| Admin | `https://jabali.lan/jabali-admin` | `admin@jabali.lan` | `q1w2E#R$` |
| User | `https://jabali.lan/jabali-panel` | `user@jabali.lan` | `wjqr9t6Z#%r&@C$4` |
### Demo Credentials
| Panel | URL | Email | Password |
|-------|-----|-------|----------|
| Admin | `https://demo.jabali-panel.com/jabali-admin` | `admin@jabali-panel.com` | `demo1234` |
| User | `https://demo.jabali-panel.com/jabali-panel` | `demo@jabali-panel.com` | `demo1234` |
**Demo mode behavior**
- `JABALI_DEMO=1` enables read-only mode via `App\Http\Middleware\DemoReadOnly`.
- Livewire `authenticate` calls are allowed for unauthenticated users.
- Some pages use static demo data to avoid agent socket calls.
- Reverse proxy must set `X-Forwarded-Proto` and the app must trust proxies for HTTPS Livewire updates.
## Models
| Model | Table | Description |
@@ -608,7 +621,15 @@ All administrative actions are logged to the `audit_logs` table.
- **USE Tailwind classes** - Only when absolutely necessary for minor adjustments
- **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
Use these Filament native components exclusively:
| Category | Components |

View File

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

View File

@@ -1,6 +1,6 @@
# CONTEXT.md
Last updated: 2026-02-01
Last updated: 2026-02-03
## Stack
- Laravel 12, Filament v5, Livewire v4
@@ -11,6 +11,16 @@ Last updated: 2026-02-01
- Admin panel: `/jabali-admin`
- User panel: `/jabali-panel`
## Demo
- Public demo: `https://demo.jabali-panel.com`
- Demo container port: `5555` behind Nginx TLS proxy
- Demo DB: `database/database-demo.sqlite`
- Demo mode: `JABALI_DEMO=1` (read-only)
- Demo credentials:
- Admin: `admin@jabali-panel.com` / `demo1234`
- User: `demo@jabali-panel.com` / `demo1234`
- Livewire HTTPS requires proxy trust (`trustProxies(at: '*')`) and `X-Forwarded-Proto`.
## Data
- Panel config DB: SQLite at `database/database.sqlite`
- Hosting services use MariaDB/Postfix/Dovecot/etc. as configured by the agent

View File

@@ -6,3 +6,8 @@
- Asset builds must be writable for `public/build` and `node_modules`; upgrade checks both.
- Installer builds assets as `www-data` to avoid permission issues.
- Default panel database is SQLite (`database/database.sqlite`).
## 2026-02-03
- Demo mode is enforced by `App\Http\Middleware\DemoReadOnly` (read-only, but allow Livewire `authenticate`).
- Demo container runs without agent sockets; select pages use static demo data to avoid 500s.
- Reverse proxies must be trusted for HTTPS Livewire updates in demo (`trustProxies(at: '*')`).

View File

@@ -3,22 +3,48 @@
</p>
<h1 align="center">Jabali Panel</h1>
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 an agent for privileged 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. Administrators get clear visibility into services, SSL, DNS, backups, and security posture, while users get a streamlined workflow for domains, email, WordPress, files, databases, and PHP settings. The goal is simple: reduce the operational burden of hosting by making common tasks safe, repeatable, and easy to audit.
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-rc48 (release candidate)
Version: see `VERSION` (release candidate)
This is a release candidate. Expect rapid iteration and breaking changes until 1.0.
## Demo and Website
- Demo: https://jabali-panel.com/demo/
- Website: https://jabali-panel.com/
## Highlights
- Per-user Linux accounts and PHP-FPM isolation
- Root agent for DNS, SSL, mail, backups, and migrations
- Health monitor with auto-restarts and alerts
- cPanel and WHM migrations with step-by-step logs
- Built-in mail stack with webmail SSO
- DNS templates with optional DNSSEC
- User and server backups with schedules and retention
- WordPress management (install, updates, scans, and SSO)
- Security center with firewall, Fail2ban, and ClamAV
- Security center with firewall, Fail2ban, ClamAV, and scanners
- Audit logs and admin notifications
## Screenshots
Admin panel:
- [Admin Dashboard](docs/screenshots/admin-dashboard.png)
- [Admin Server Status](docs/screenshots/admin-server-status.png)
- [Admin Server Settings](docs/screenshots/admin-server-settings.png)
- [Admin Security](docs/screenshots/admin-security.png)
- [Admin Users](docs/screenshots/admin-users.png)
- [Admin SSL Manager](docs/screenshots/admin-ssl-manager.png)
- [Admin DNS Zones](docs/screenshots/admin-dns-zones.png)
- [Admin Backups](docs/screenshots/admin-backups.png)
- [Admin Services](docs/screenshots/admin-services.png)
User panel:
- [User Dashboard](docs/screenshots/user-dashboard.png)
- [User Domains](docs/screenshots/user-domains.png)
- [User Backups](docs/screenshots/user-backups.png)
- [User cPanel Migration](docs/screenshots/user-cpanel-migration.png)
## Installation
@@ -33,12 +59,42 @@ Optional flags:
- `JABALI_MINIMAL=1` for core-only install
- `JABALI_FULL=1` to force all optional components
Debian packages:
```
./scripts/build-jabali-deps-deb.sh
./scripts/build-jabali-panel-deb.sh
sudo dpkg -i ./jabali-deps_<version>_all.deb
sudo apt-get -f install -y
sudo dpkg -i ./jabali-panel_<version>_all.deb
```
After install:
- Admin panel: `https://your-host/jabali-admin`
- User panel: `https://your-host/jabali-panel`
- Webmail: `https://your-host/webmail`
Website: https://jabali-panel.com/
## Demo
Public demo:
- User panel: https://demo.jabali-panel.com/jabali-panel/
- Admin panel: https://demo.jabali-panel.com/jabali-admin/
Credentials:
- Admin: `admin@jabali-panel.com` / `demo1234`
- User: `demo@jabali-panel.com` / `demo1234`
Notes:
- Demo is read-only; actions that change data are blocked.
- Some pages use static demo data where the privileged agent is unavailable
(for example: PHP Manager, PHP Settings, Protected Directories).
## Feature Map
### Admin Panel
@@ -79,33 +135,13 @@ After install:
- Redis ACL isolation for WordPress caching
- Multi-language UI
## Screenshots
Admin panel:
- Dashboard: ![Admin Dashboard](docs/screenshots/admin-dashboard.png)
- Server Status: ![Server Status](docs/screenshots/admin-server-status.png)
- Server Settings: ![Server Settings](docs/screenshots/admin-server-settings.png)
- Security Center: ![Security Center](docs/screenshots/admin-security.png)
- Users: ![User Management](docs/screenshots/admin-users.png)
- SSL Manager: ![SSL Manager](docs/screenshots/admin-ssl-manager.png)
- DNS Zones: ![DNS Zones](docs/screenshots/admin-dns-zones.png)
- Backups: ![Admin Backups](docs/screenshots/admin-backups.png)
- Services: ![Services](docs/screenshots/admin-services.png)
User panel:
- Dashboard: ![User Dashboard](docs/screenshots/user-dashboard.png)
- Domain Management: ![User Domains](docs/screenshots/user-domains.png)
- Backups: ![User Backups](docs/screenshots/user-backups.png)
- cPanel Migration: ![cPanel Migration](docs/screenshots/user-cpanel-migration.png)
## Architecture
- Control plane: Laravel app with Filament panels
- Control plane: Laravel 12 app with Filament v5 and Livewire v4
- Data plane: root agent handling privileged operations
- Job queue: async tasks and migration steps
- Logging: panel and agent logs for troubleshooting
- Server metrics: sysstat logs via SysstatMetrics
Service stack (single-node default):
@@ -124,6 +160,13 @@ Service stack (single-node default):
- PTR (reverse DNS) for mail hostname
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
## Security Hardening
- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies).
- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost.
- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification.
- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256).
## Upgrades
```
@@ -158,3 +201,8 @@ php artisan test --compact
## License
MIT
## Documentation Notes
- Documentation screenshots are generated for all admin and user pages.
- cPanel Migration tabs (Domains, Databases, Mailboxes, Forwarders, SSL) only render after a backup is analyzed. Provide a sample cPanel backup to capture those tab screenshots.

View File

@@ -6,3 +6,5 @@ Keep this list current as work progresses.
- [ ] Confirm WAF whitelist + blocked requests tables refresh correctly after changes.
- [ ] Validate sysstat collection interval (10s) and chart intervals align.
- [ ] Audit installer/uninstaller parity for newly added packages.
- [ ] Demo mode: stub remaining agent-dependent pages to avoid 500s.
- [ ] Demo mode: reduce repeated "changes are disabled" notifications for blocked actions.

View File

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

View File

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

View File

@@ -8,10 +8,23 @@ use App\Models\User;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Facades\Filament;
use Illuminate\Support\HtmlString;
use Illuminate\Support\Facades\Hash;
class Login extends BaseLogin
{
public function getSubheading(): string | HtmlString | null
{
if (env('JABALI_DEMO', false)) {
return new HtmlString(
__('Demo credentials') .
': <code>admin@jabali-panel.com</code> / <code>demo1234</code>'
);
}
return parent::getSubheading();
}
public function authenticate(): ?LoginResponse
{
$data = $this->form->getState();

View File

@@ -324,7 +324,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
->color('gray'),
TextColumn::make('duration')
->label(__('Duration'))
->placeholder('-')
->placeholder(__('-'))
->color('gray'),
])
->recordActions([

View File

@@ -574,7 +574,7 @@ class CpanelMigration extends Page implements HasActions, HasForms, HasInfolists
Grid::make(['default' => 1, 'sm' => 2])->schema([
TextInput::make('hostname')
->label(__('cPanel Hostname'))
->placeholder('cpanel.example.com')
->placeholder(__('cpanel.example.com'))
->required(fn () => $this->sourceType === 'remote')
->helperText(__('Your cPanel server hostname or IP address')),
TextInput::make('port')
@@ -610,7 +610,7 @@ class CpanelMigration extends Page implements HasActions, HasForms, HasInfolists
->schema([
TextInput::make('localBackupPath')
->label(__('Backup File Path'))
->placeholder('/home/user/backups/backup-date_username.tar.gz')
->placeholder(__('/home/user/backups/backup-date_username.tar.gz'))
->required(fn () => $this->sourceType === 'local')
->helperText(__('Full path to the cPanel backup file (e.g., /var/backups/backup.tar.gz)')),
Text::make(__('Supported formats: .tar.gz, .tgz'))->color('gray'),

View File

@@ -14,9 +14,12 @@ use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
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
{
return [
@@ -78,27 +88,86 @@ class Dashboard extends Page implements HasActions, HasForms
->color('gray')
->action(fn () => $this->redirect(request()->url())),
Action::make('onboarding')
Action::make('onboarding')->modalCancelActionLabel('Maybe later')
->label(__('Setup Wizard'))
->icon('heroicon-o-sparkles')
->visible(fn () => ! DnsSetting::get('onboarding_completed', false))
->modalHeading(__('Welcome to Jabali!'))
->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([
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')
->label(__('Your Email Address'))
->helperText(__('Enter your email to receive important server notifications.'))
->email()
->placeholder('admin@example.com'),
->placeholder(__('admin@example.com')),
])
->modalSubmitActionLabel(__('Get Started'))
->modalSubmitActionLabel(__('Save and close'))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
if ($adminEmail !== '') {
DnsSetting::set('admin_email_recipients', $adminEmail);
}
DnsSetting::set('onboarding_completed', '1');
DnsSetting::clearCache();
Notification::make()
->title(__('Setup saved'))
->body(__('Your notification email has been updated.'))
->success()
->send();
}),
];
}

View File

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

View File

@@ -254,12 +254,12 @@ class DnsZones extends Page implements HasActions, HasForms, HasTable
->sortable(),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-')
->placeholder(__('-'))
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
->sortable(),
TextColumn::make('domain.user.username')
->label(__('Owner'))
->placeholder('N/A')
->placeholder(__('N/A'))
->sortable(),
])
->filters([])

View File

@@ -99,7 +99,7 @@ class IpAddresses extends Page implements HasActions, HasTable
->form([
TextInput::make('ip')
->label(__('IP Address'))
->placeholder('203.0.113.10')
->placeholder(__('203.0.113.10'))
->live()
->afterStateUpdated(function (?string $state, callable $set): void {
if (! $state) {
@@ -198,7 +198,7 @@ class IpAddresses extends Page implements HasActions, HasTable
->getStateUsing(fn (array $record): ?string => $this->getDefaultLabel($record))
->badge()
->color('success')
->placeholder('-'),
->placeholder(__('-')),
])
->recordActions([
Action::make('setDefault')

View File

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

View File

@@ -65,7 +65,30 @@ class PhpManager extends Page implements HasActions, HasForms, HasTable
public function loadPhpVersions(): void
{
if ((bool) env('JABALI_DEMO', false)) {
$this->installedVersions = [
['version' => '8.4', 'fpm_status' => 'active'],
['version' => '8.3', 'fpm_status' => 'active'],
['version' => '8.2', 'fpm_status' => 'inactive'],
['version' => '8.1', 'fpm_status' => 'inactive'],
];
$this->defaultVersion = '8.4';
$allVersions = ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'];
$installed = array_column($this->installedVersions, 'version');
$this->availableVersions = array_diff($allVersions, $installed);
return;
}
try {
$result = $this->getAgent()->send('php.list_versions', []);
} catch (\Exception $e) {
$this->installedVersions = [];
$this->defaultVersion = null;
$this->availableVersions = [];
return;
}
if ($result['success'] ?? false) {
$this->installedVersions = $result['versions'] ?? [];

View File

@@ -575,7 +575,9 @@ class Security extends Page implements HasActions, HasForms, HasTable
->visible(fn () => $this->fail2banRunning)
->requiresConfirmation()
->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'),
])
->schema([
@@ -665,9 +667,12 @@ class Security extends Page implements HasActions, HasForms, HasTable
->color(fn () => $this->clamavRunning ? 'warning' : 'success')
->size('sm')
->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
? __('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?')),
FormAction::make('updateSignatures')
->label(__('Update Signatures'))

View File

@@ -279,7 +279,7 @@ class ServerSettings extends Page implements HasActions, HasForms
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('brandingData.panel_name')
->label(__('Control Panel Name'))
->placeholder('Jabali')
->placeholder(__('Jabali'))
->helperText(__('Appears in browser title and navigation'))
->required(),
]),
@@ -320,7 +320,7 @@ class ServerSettings extends Page implements HasActions, HasForms
->schema([
TextInput::make('hostnameData.hostname')
->label(__('Hostname'))
->placeholder('server.example.com')
->placeholder(__('server.example.com'))
->required(),
Actions::make([
FormAction::make('saveHostname')
@@ -338,10 +338,10 @@ class ServerSettings extends Page implements HasActions, HasForms
->icon('heroicon-o-server-stack')
->schema([
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
TextInput::make('dnsData.ns1')->label(__('NS1 Hostname'))->placeholder('ns1.example.com'),
TextInput::make('dnsData.ns1_ip')->label(__('NS1 IP Address'))->placeholder('192.168.1.1'),
TextInput::make('dnsData.ns2')->label(__('NS2 Hostname'))->placeholder('ns2.example.com'),
TextInput::make('dnsData.ns2_ip')->label(__('NS2 IP Address'))->placeholder('192.168.1.2'),
TextInput::make('dnsData.ns1')->label(__('NS1 Hostname'))->placeholder(__('ns1.example.com')),
TextInput::make('dnsData.ns1_ip')->label(__('NS1 IP Address'))->placeholder(__('192.168.1.1')),
TextInput::make('dnsData.ns2')->label(__('NS2 Hostname'))->placeholder(__('ns2.example.com')),
TextInput::make('dnsData.ns2_ip')->label(__('NS2 IP Address'))->placeholder(__('192.168.1.2')),
]),
]),
Section::make(__('Zone Defaults'))
@@ -349,20 +349,20 @@ class ServerSettings extends Page implements HasActions, HasForms
Grid::make(['default' => 1, 'md' => 3])->schema([
TextInput::make('dnsData.default_ip')
->label(__('Default Server IP'))
->placeholder('192.168.1.1')
->placeholder(__('192.168.1.1'))
->helperText(__('Default A record IP for new zones')),
TextInput::make('dnsData.default_ipv6')
->label(__('Default IPv6'))
->placeholder('2001:db8::1')
->placeholder(__('2001:db8::1'))
->helperText(__('Default AAAA record IP for new zones'))
->rule('nullable|ipv6'),
TextInput::make('dnsData.default_ttl')
->label(__('Default TTL'))
->placeholder('3600'),
->placeholder(__('3600')),
]),
TextInput::make('dnsData.admin_email')
->label(__('Admin Email (SOA)'))
->placeholder('admin.example.com')
->placeholder(__('admin.example.com'))
->helperText(__('Use dots instead of @ (e.g., admin.example.com)')),
Actions::make([
FormAction::make('saveDns')
@@ -386,10 +386,10 @@ class ServerSettings extends Page implements HasActions, HasForms
->action('applyQuad9Resolvers'),
])->alignment('left'),
Grid::make(['default' => 1, 'md' => 2, 'lg' => 4])->schema([
TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder('8.8.8.8'),
TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder('8.8.4.4'),
TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder('1.1.1.1'),
TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder('example.com'),
TextInput::make('resolversData.resolver1')->label(__('Resolver 1'))->placeholder(__('8.8.8.8')),
TextInput::make('resolversData.resolver2')->label(__('Resolver 2'))->placeholder(__('8.8.4.4')),
TextInput::make('resolversData.resolver3')->label(__('Resolver 3'))->placeholder(__('1.1.1.1')),
TextInput::make('resolversData.search_domain')->label(__('Search Domain'))->placeholder(__('example.com')),
]),
Actions::make([
FormAction::make('saveResolvers')
@@ -470,7 +470,7 @@ class ServerSettings extends Page implements HasActions, HasForms
TextInput::make('quotaData.default_quota_mb')
->label(__('Default Quota (MB)'))
->numeric()
->placeholder('5120')
->placeholder(__('5120'))
->helperText(__('Default disk quota for new users (5120 MB = 5 GB)')),
]),
Actions::make([
@@ -487,7 +487,7 @@ class ServerSettings extends Page implements HasActions, HasForms
->numeric()
->minValue(1)
->maxValue(500)
->placeholder('100')
->placeholder(__('100'))
->helperText(__('Maximum file size users can upload (1-500 MB)')),
Actions::make([
FormAction::make('saveFileManagerSettings')
@@ -507,7 +507,7 @@ class ServerSettings extends Page implements HasActions, HasForms
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('emailData.mail_hostname')
->label(__('Mail Server Hostname'))
->placeholder('mail.example.com')
->placeholder(__('mail.example.com'))
->helperText(__('The hostname used for mail server identification')),
TextInput::make('emailData.mail_default_quota_mb')
->label(__('Default Mailbox Quota (MB)'))
@@ -527,11 +527,11 @@ class ServerSettings extends Page implements HasActions, HasForms
Grid::make(['default' => 1, 'md' => 2])->schema([
TextInput::make('emailData.webmail_url')
->label(__('Webmail URL'))
->placeholder('/webmail')
->placeholder(__('/webmail'))
->helperText(__('URL path for Roundcube webmail')),
TextInput::make('emailData.webmail_product_name')
->label(__('Webmail Product Name'))
->placeholder('Jabali Webmail')
->placeholder(__('Jabali Webmail'))
->helperText(__('Name displayed on the webmail login page')),
]),
Actions::make([
@@ -556,7 +556,7 @@ class ServerSettings extends Page implements HasActions, HasForms
->schema([
TextInput::make('notificationsData.admin_email_recipients')
->label(__('Email Addresses'))
->placeholder('admin@example.com, alerts@example.com')
->placeholder(__('admin@example.com, alerts@example.com'))
->helperText(__('Comma-separated list of email addresses to receive notifications')),
]),
Section::make(__('Notification Types & High Load Alerts'))
@@ -598,14 +598,14 @@ class ServerSettings extends Page implements HasActions, HasForms
->minValue(1)
->maxValue(100)
->step(0.5)
->placeholder('5')
->placeholder(__('5'))
->helperText(__('Alert when load exceeds this value')),
TextInput::make('notificationsData.load_alert_minutes')
->label(__('Alert After (minutes)'))
->numeric()
->minValue(1)
->maxValue(60)
->placeholder('5')
->placeholder(__('5'))
->helperText(__('Minutes of high load before alerting')),
]),
Actions::make([
@@ -649,7 +649,7 @@ class ServerSettings extends Page implements HasActions, HasForms
->helperText(__('Requests before worker recycle')),
TextInput::make('phpFpmData.memory_limit')
->label(__('Memory Limit'))
->placeholder('512M')
->placeholder(__('512M'))
->helperText(__('PHP memory_limit (e.g., 512M, 1G)')),
]),
Grid::make(['default' => 1, 'md' => 2, 'lg' => 3])->schema([
@@ -692,17 +692,17 @@ class ServerSettings extends Page implements HasActions, HasForms
protected function databaseTabContent(): array
{
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'))
->description(__('Adjust MariaDB/MySQL global variables.'))
->icon('heroicon-o-circle-stack')
->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),
]),
];

View File

@@ -208,7 +208,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_active'])
->requiresConfirmation()
->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'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'stop')),
Action::make('restart')
@@ -236,7 +238,9 @@ class Services extends Page implements HasActions, HasForms, HasTable
->visible(fn (array $record): bool => $record['is_enabled'])
->requiresConfirmation()
->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'))
->action(fn (array $record) => $this->executeServiceAction($record['service'], 'disable')),
])

View File

@@ -123,7 +123,7 @@ class SslManager extends Page implements HasTable
->limit(30)
->tooltip(fn ($state) => $state)
->color('danger')
->placeholder('-'),
->placeholder(__('-')),
])
->filters([
SelectFilter::make('ssl_status')

View File

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

View File

@@ -382,7 +382,7 @@ class WhmMigration extends Page implements HasActions, HasForms, HasInfolists, H
Grid::make(['default' => 1, 'sm' => 2])->schema([
TextInput::make('hostname')
->label(__('WHM Hostname'))
->placeholder('whm.example.com')
->placeholder(__('whm.example.com'))
->required()
->helperText(__('Your WHM server hostname or IP address')),
TextInput::make('port')

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ class DnsPendingAddsTable extends Component implements HasActions, HasSchemas, H
->label(__('TTL')),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-'),
->placeholder(__('-')),
])
->actions([
Action::make('removePending')

View File

@@ -53,7 +53,7 @@ class Fail2banLogsTable extends Component implements HasActions, HasSchemas, Has
->label(__('IP'))
->fontFamily('mono')
->copyable()
->placeholder('-'),
->placeholder(__('-')),
TextColumn::make('message')
->label(__('Message'))
->wrap(),

View File

@@ -12,9 +12,22 @@ use Filament\Facades\Filament;
use Filament\Models\Contracts\FilamentUser;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
class Login extends BaseLogin
{
public function getSubheading(): string | HtmlString | null
{
if (env('JABALI_DEMO', false)) {
return new HtmlString(
__('Demo credentials') .
': <code>demo@jabali-panel.com</code> / <code>demo1234</code>'
);
}
return parent::getSubheading();
}
public function authenticate(): ?LoginResponse
{
$panel = Filament::getPanel('jabali');

View File

@@ -373,7 +373,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
->sortable(),
TextColumn::make('duration')
->label(__('Duration'))
->placeholder('-'),
->placeholder(__('-')),
])
->defaultSort('created_at', 'desc')
->emptyStateHeading(__('No restore history'))
@@ -1092,7 +1092,7 @@ class Backups extends Page implements HasActions, HasForms, HasTable
Grid::make(2)->schema([
TextInput::make('host')
->label(__('Host'))
->placeholder('backup.example.com')
->placeholder(__('backup.example.com'))
->required(),
TextInput::make('port')
->label(__('Port'))

View File

@@ -46,6 +46,8 @@ class CpanelMigration extends Page implements HasActions, HasForms
protected static ?string $navigationLabel = null;
protected static bool $shouldRegisterNavigation = false;
public static function getNavigationLabel(): string
{
return __('cPanel Migration');
@@ -571,7 +573,7 @@ class CpanelMigration extends Page implements HasActions, HasForms
Grid::make(['default' => 1, 'sm' => 2])->schema([
TextInput::make('hostname')
->label(__('cPanel Hostname'))
->placeholder('cpanel.example.com')
->placeholder(__('cpanel.example.com'))
->required()
->helperText(__('Your cPanel server hostname or IP address')),
TextInput::make('port')

View File

@@ -9,8 +9,13 @@ use App\Filament\Jabali\Widgets\DomainsWidget;
use App\Filament\Jabali\Widgets\MailboxesWidget;
use App\Filament\Jabali\Widgets\RecentBackupsWidget;
use App\Filament\Jabali\Widgets\StatsOverview;
use App\Models\DnsSetting;
use BackedEnum;
use Filament\Actions\Action;
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;
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
{
$user = Auth::user();

View File

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

View File

@@ -288,7 +288,7 @@ class DnsRecords extends Page implements HasActions, HasForms, HasTable
->sortable(),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-')
->placeholder(__('-'))
->color(fn (DnsRecord $record) => $this->isRecordPendingDelete($record->id) ? 'danger' : null)
->sortable(),
])

View File

@@ -248,7 +248,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
->schema([
TextInput::make('domain_redirect_url')
->label(__('Redirect To'))
->placeholder('https://newdomain.com')
->placeholder(__('https://newdomain.com'))
->helperText(__('All requests to this domain will be redirected to this URL'))
->url()
->required(fn ($get) => $get('domain_redirect_enabled'))
@@ -275,13 +275,13 @@ class Domains extends Page implements HasActions, HasForms, HasTable
->schema([
TextInput::make('source_path')
->label(__('Source Path'))
->placeholder('/old-page')
->placeholder(__('/old-page'))
->helperText(__('Path to redirect from (e.g., /old-page)'))
->required()
->columnSpan(['default' => 2, 'md' => 1]),
TextInput::make('destination_url')
->label(__('Destination URL'))
->placeholder('https://example.com/new-page')
->placeholder(__('https://example.com/new-page'))
->helperText(__('Full URL to redirect to'))
->required()
->url()
@@ -359,13 +359,13 @@ class Domains extends Page implements HasActions, HasForms, HasTable
Textarea::make('allowed_domains')
->label(__('Allowed Domains'))
->helperText(__('One domain per line that can link to your files (your own domain is always allowed)'))
->placeholder("example.com\ntrusted-site.com")
->placeholder(__("example.com\ntrusted-site.com"))
->rows(4)
->columnSpan(['default' => 2, 'md' => 1]),
TextInput::make('protected_extensions')
->label(__('Protected File Extensions'))
->helperText(__('Comma-separated list of file extensions to protect'))
->placeholder('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf')
->placeholder(__('jpg,jpeg,png,gif,webp,svg,mp4,mp3,pdf'))
->default(DomainHotlinkSetting::getDefaultExtensions())
->columnSpan(['default' => 2, 'md' => 1]),
])
@@ -381,7 +381,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
TextInput::make('redirect_url')
->label(__('Redirect URL (Optional)'))
->helperText(__('Redirect blocked requests to this URL instead of showing an error'))
->placeholder('https://example.com/hotlink-blocked.png')
->placeholder(__('https://example.com/hotlink-blocked.png'))
->url()
->columnSpan(['default' => 2, 'md' => 1]),
])
@@ -842,7 +842,7 @@ class Domains extends Page implements HasActions, HasForms, HasTable
->schema([
TextInput::make('alias')
->label(__('Alias Domain'))
->placeholder('alias-example.com')
->placeholder(__('alias-example.com'))
->required()
->rule('regex:/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\\.[a-z]{2,}$/')
->helperText(__('Enter a full domain name.')),

View File

@@ -32,6 +32,7 @@ use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs;
use Filament\Schemas\Components\Tabs\Tab;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
@@ -185,11 +186,11 @@ class Email extends Page implements HasActions, HasForms, HasTable
Textarea::make('whitelist')
->label(__('Whitelist (one per line)'))
->rows(6)
->placeholder("friend@example.com\ntrusted.com"),
->placeholder(__("friend@example.com\ntrusted.com")),
Textarea::make('blacklist')
->label(__('Blacklist (one per line)'))
->rows(6)
->placeholder("spam@example.com\nbad-domain.com"),
->placeholder(__("spam@example.com\nbad-domain.com")),
TextInput::make('score')
->label(__('Spam Score Threshold'))
->numeric()
@@ -1017,21 +1018,26 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
->searchable(),
->searchable()
->live()
->live(),
TextInput::make('local_part')
->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._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('name')
->label(__('Display Name'))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->maxLength(255),
TextInput::make('password')
->label(__('Password'))
->password()
->revealable()
->required()
->required(fn (Get $get): bool => filled($get('domain_id')))
->visible(fn (Get $get): bool => filled($get('domain_id')))
->minLength(8)
->rules([
'regex:/[a-z]/', // lowercase
@@ -1063,6 +1069,7 @@ class Email extends Page implements HasActions, HasForms, HasTable
TextInput::make('quota_mb')
->label(__('Quota (MB)'))
->numeric()
->visible(fn (Get $get): bool => filled($get('domain_id')))
->default(1024)
->minValue(100)
->maxValue(10240)
@@ -1236,16 +1243,19 @@ class Email extends Page implements HasActions, HasForms, HasTable
->label(__('Domain'))
->options(fn () => Domain::where('user_id', Auth::id())->pluck('domain', 'id')->toArray())
->required()
->searchable(),
->searchable()
->live(),
TextInput::make('local_part')
->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._%+-]+$/')
->maxLength(64)
->helperText(__('The part before the @ symbol')),
TextInput::make('destinations')
->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')),
])
->action(function (array $data): void {

View File

@@ -60,7 +60,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
public function getTitle(): string|Htmlable
{
return 'File Manager';
return __('File Manager');
}
public function getAgent(): AgentClient
@@ -83,7 +83,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
// Invalid path from URL - reset to home directory
$this->currentPath = '';
Notification::make()
->title('Invalid path')
->title(__('Invalid path'))
->body('The requested path is not allowed.')
->danger()
->send();
@@ -222,7 +222,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
} catch (Exception $e) {
$this->items = [];
Notification::make()
->title('Error loading directory')
->title(__('Error loading directory'))
->body($e->getMessage())
->danger()
->send();
@@ -237,7 +237,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title('Invalid path')
->title(__('Invalid path'))
->body($e->getMessage())
->danger()
->send();
@@ -425,7 +425,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
->form([
TextInput::make('mode')
->label(__('Numeric Mode'))
->placeholder('755')
->placeholder(__('755'))
->maxLength(4)
->helperText(__('Enter octal mode (e.g., 755, 644)')),
Grid::make(3)
@@ -750,7 +750,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
$this->getAgent()->fileMove($this->getUsername(), $sourcePath, $destPath);
Notification::make()
->title('Item moved successfully')
->title(__('Item moved successfully'))
->success()
->send();
@@ -758,7 +758,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title('Error moving item')
->title(__('Error moving item'))
->body($e->getMessage())
->danger()
->send();
@@ -788,7 +788,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
);
Notification::make()
->title("Uploaded: $filename")
->title(__('Uploaded: :filename', ['filename' => $filename]))
->success()
->send();
@@ -796,7 +796,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
$this->resetTable();
} catch (Exception $e) {
Notification::make()
->title("Upload failed: $filename")
->title(__('Upload failed: :filename', ['filename' => $filename]))
->body($e->getMessage())
->danger()
->send();
@@ -939,7 +939,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
$uploaded++;
} catch (Exception $e) {
Notification::make()
->title(__('Upload failed: ').$file->getClientOriginalName())
->title(__('Upload failed: :filename', ['filename' => $file->getClientOriginalName()]))
->body($e->getMessage())
->danger()
->send();
@@ -1007,7 +1007,7 @@ class Files extends Page implements HasActions, HasForms, HasTable
filename: basename($path)
);
} catch (Exception $e) {
Notification::make()->title('Error downloading')->body($e->getMessage())->danger()->send();
Notification::make()->title(__('Error downloading'))->body($e->getMessage())->danger()->send();
}
}

View File

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

View File

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

View File

@@ -82,7 +82,7 @@ class MailingLists extends Page implements HasActions, HasForms
->schema([
TextInput::make('listmonk_url')
->label(__('Listmonk URL'))
->placeholder('https://lists.example.com')
->placeholder(__('https://lists.example.com'))
->url()
->visible(fn ($get) => $get('provider') === 'listmonk'),
TextInput::make('listmonk_token')
@@ -99,7 +99,7 @@ class MailingLists extends Page implements HasActions, HasForms
->schema([
TextInput::make('mailman_url')
->label(__('Mailman URL'))
->placeholder('https://lists.example.com/mailman')
->placeholder(__('https://lists.example.com/mailman'))
->url()
->visible(fn ($get) => $get('provider') === 'mailman'),
TextInput::make('mailman_admin')

View File

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

View File

@@ -83,6 +83,16 @@ class PhpSettings extends Page implements HasActions, HasForms
protected function loadDomains(): void
{
if ((bool) env('JABALI_DEMO', false)) {
$this->domains = [
['domain' => 'jabali-panel.com'],
['domain' => 'demo-site.com'],
['domain' => 'store.demo'],
];
return;
}
$result = $this->getAgent()->send('domain.list', [
'username' => $this->getUsername(),
]);
@@ -92,6 +102,17 @@ class PhpSettings extends Page implements HasActions, HasForms
protected function loadPhpVersions(): void
{
if ((bool) env('JABALI_DEMO', false)) {
$this->phpVersions = [
'8.4' => 'PHP 8.4',
'8.3' => 'PHP 8.3',
'8.2' => 'PHP 8.2',
'8.1' => 'PHP 8.1',
];
return;
}
$result = $this->getAgent()->send('php.list_versions', []);
$this->phpVersions = [];
@@ -120,6 +141,20 @@ class PhpSettings extends Page implements HasActions, HasForms
return;
}
if ((bool) env('JABALI_DEMO', false)) {
$this->data = [
'php_version' => array_key_first($this->phpVersions),
'memory_limit' => '256M',
'upload_max_filesize' => '64M',
'post_max_size' => '64M',
'max_input_vars' => '3000',
'max_execution_time' => '300',
'max_input_time' => '300',
];
return;
}
$result = $this->getAgent()->send('php.getSettings', [
'domain' => $this->selectedDomain,
'username' => $this->getUsername(),

View File

@@ -80,6 +80,16 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
protected function loadDomains(): void
{
if ((bool) env('JABALI_DEMO', false)) {
$this->domains = [
['domain' => 'jabali-panel.com'],
['domain' => 'demo-site.com'],
['domain' => 'store.demo'],
];
return;
}
$result = $this->getAgent()->send('domain.list', [
'username' => $this->getUsername(),
]);
@@ -102,6 +112,30 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
return;
}
if ((bool) env('JABALI_DEMO', false)) {
$this->protectedDirs = [
[
'path' => '/admin',
'name' => 'Restricted Area',
'users_count' => 2,
'users' => [
['username' => 'demo', 'created_at' => now()->subDays(10)->toDateTimeString()],
['username' => 'editor', 'created_at' => now()->subDays(3)->toDateTimeString()],
],
],
[
'path' => '/private',
'name' => 'Private Files',
'users_count' => 1,
'users' => [
['username' => 'staff', 'created_at' => now()->subDays(1)->toDateTimeString()],
],
],
];
return;
}
$result = $this->getAgent()->send('domain.list_protected_dirs', [
'domain' => $this->selectedDomain,
'username' => $this->getUsername(),
@@ -239,7 +273,7 @@ class ProtectedDirectories extends Page implements HasActions, HasForms, HasTabl
->form([
TextInput::make('path')
->label(__('Directory Path'))
->placeholder('/admin')
->placeholder(__('/admin'))
->required()
->helperText(__('Path relative to your document root (e.g., /admin, /private, /members)')),
TextInput::make('name')

View File

@@ -99,7 +99,7 @@ class Ssl extends Page implements HasActions, HasForms, HasTable
? __('Expired :days days ago', ['days' => abs($record->sslCertificate->days_until_expiry)])
: __(':days days left', ['days' => $record->sslCertificate->days_until_expiry]))
: null)
->placeholder('-')
->placeholder(__('-'))
->sortable(),
IconColumn::make('sslCertificate.auto_renew')
->label(__('Auto-Renew'))
@@ -421,19 +421,19 @@ class Ssl extends Page implements HasActions, HasForms, HasTable
->helperText(__('Select the domain to install the certificate on')),
Textarea::make('certificate')
->label(__('Certificate (PEM format)'))
->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----")
->placeholder(__("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"))
->rows(8)
->required()
->helperText(__('Paste your SSL certificate in PEM format')),
Textarea::make('private_key')
->label(__('Private Key (PEM format)'))
->placeholder("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----")
->placeholder(__("-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"))
->rows(8)
->required()
->helperText(__('Paste your private key in PEM format. Keep this secure!')),
Textarea::make('ca_bundle')
->label(__('CA Bundle (optional)'))
->placeholder("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----")
->placeholder(__("-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"))
->rows(6)
->helperText(__('Paste the certificate authority chain if required by your certificate provider')),
])

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,7 @@ class DnsPendingAddsTable extends Component implements HasActions, HasSchemas, H
->label(__('TTL')),
TextColumn::make('priority')
->label(__('Priority'))
->placeholder('-'),
->placeholder(__('-')),
])
->actions([
Action::make('removePending')

View File

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

View File

@@ -232,4 +232,6 @@ class BackupSchedule extends Model
default => 'gray',
};
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

127
composer.lock generated
View File

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

26
config.toml.example Normal file
View File

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

View File

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

View File

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

4
doccs/site/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.astro/
.DS_Store

4
doccs/site/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
doccs/site/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

49
doccs/site/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Starlight Starter Kit: Basics
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── content/
│ │ └── docs/
│ └── content.config.ts
├── astro.config.mjs
├── package.json
└── tsconfig.json
```
Starlight looks for `.md` or `.mdx` files in the `src/content/docs/` directory. Each file is exposed as a route based on its file name.
Images can be added to `src/assets/` and embedded in Markdown with a relative link.
Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Check out [Starlights docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).

View File

@@ -0,0 +1,36 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
// https://astro.build/config
export default defineConfig({
vite: {
server: {
allowedHosts: true,
},
},
integrations: [
starlight({
title: 'Jabali Panel Documentation',
description: 'Feature documentation and screenshots for the Jabali hosting panel.',
sidebar: [
{
label: 'Getting Started',
items: [{ label: 'Overview', slug: 'overview' }, { label: 'Installation', slug: 'install' }, { label: 'Quickstart', slug: 'quickstart' }, { label: 'Backups and Restore', slug: 'backups-restore' }, { label: 'Migrations', slug: 'migrations' }, { label: 'DNS and Mail', slug: 'dns-mail' }, { label: 'Operations', slug: 'operations' }, { label: 'Security', slug: 'security' }, { label: 'Troubleshooting', slug: 'troubleshooting' }],
},
{
label: 'Admin Panel',
autogenerate: { directory: 'admin' },
},
{
label: 'User Panel',
autogenerate: { directory: 'user' },
},
{
label: 'Platform',
autogenerate: { directory: 'platform' },
},
],
}),
],
});

6394
doccs/site/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
doccs/site/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "doccs-site",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=jabali.lan astro dev",
"start": "__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=jabali.lan astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.37.6",
"astro": "^5.6.1",
"sharp": "^0.34.2"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Some files were not shown because too many files have changed in this diff Show More