Compare commits

...

7 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
32 changed files with 1904 additions and 311 deletions

View File

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

3
.gitignore vendored
View File

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

View File

@@ -58,6 +58,7 @@ php artisan route:cache # Cache routes
## Git Workflow
**Important:** Only push to git when explicitly requested by the user. Do not auto-push after commits.
**Important:** Push to GitHub from the test server `root@192.168.100.50` (where the GitHub deploy key is configured).
### Version Numbers

View File

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

View File

@@ -160,6 +160,13 @@ Service stack (single-node default):
- PTR (reverse DNS) for mail hostname
- Open ports: 22, 80, 443, 25, 465, 587, 993, 995, 53
## Security Hardening
- `TRUSTED_PROXIES`: comma-separated proxy IPs/CIDRs (or `*` if you intentionally trust all upstream proxies).
- `JABALI_INTERNAL_API_TOKEN`: optional shared token for internal API calls that do not originate from localhost.
- `JABALI_IMPORT_INSECURE_TLS`: optional escape hatch for remote migration discovery. Leave unset for strict TLS verification.
- Git deployment webhooks support signed payloads via `X-Jabali-Signature` / `X-Hub-Signature-256` (HMAC-SHA256).
## Upgrades
```

View File

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

View File

@@ -213,7 +213,7 @@ class ImportProcessCommand extends Command
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}");
@@ -235,7 +235,7 @@ class ImportProcessCommand extends Command
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}");
@@ -290,7 +290,7 @@ 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');
@@ -316,7 +316,7 @@ 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');

View File

@@ -14,6 +14,7 @@ use Filament\Actions\Contracts\HasActions;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\EmbeddedTable;
use Filament\Schemas\Components\Grid;
@@ -94,6 +95,14 @@ class Dashboard extends Page implements HasActions, HasForms
->modalHeading(__('Welcome to Jabali!'))
->modalDescription(__('Let\'s get your server control panel set up.'))
->modalWidth('2xl')
->fillForm(function (): array {
$savedRecipients = trim((string) DnsSetting::get('admin_email_recipients', ''));
$savedPrimaryEmail = $savedRecipients === '' ? '' : trim(explode(',', $savedRecipients)[0]);
return [
'admin_email' => $savedPrimaryEmail,
];
})
->form([
Section::make(__('Next Steps'))
->description(__('Here is a quick setup path to get your first site online.'))
@@ -143,13 +152,22 @@ class Dashboard extends Page implements HasActions, HasForms
->email()
->placeholder(__('admin@example.com')),
])
->modalSubmitActionLabel(__("Don't show again"))
->modalSubmitActionLabel(__('Save and close'))
->action(function (array $data): void {
if (! empty($data['admin_email'])) {
DnsSetting::set('admin_email_recipients', $data['admin_email']);
$adminEmail = trim((string) ($data['admin_email'] ?? ''));
if ($adminEmail !== '') {
DnsSetting::set('admin_email_recipients', $adminEmail);
}
DnsSetting::set('onboarding_completed', '1');
DnsSetting::clearCache();
Notification::make()
->title(__('Setup saved'))
->body(__('Your notification email has been updated.'))
->success()
->send();
}),
];
}

View File

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

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Filament\Jabali\Pages;
use App\Models\Domain;
use App\Models\DnsRecord;
use App\Models\DnsSetting;
use App\Models\MysqlCredential;
use App\Services\Agent\AgentClient;
use BackedEnum;
@@ -205,16 +207,46 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
->modalDescription(__('This will create a copy of your site for testing.'))
->modalIcon('heroicon-o-document-duplicate')
->modalIconColor('info')
->form([
->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')
@@ -259,6 +291,17 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
);
if ($result['success'] ?? false) {
if (($record['is_staging'] ?? false)) {
$this->removeStagingDnsRecords($record);
}
if (($record['is_staging'] ?? false) && ! empty($record['domain'])) {
Domain::query()
->where('user_id', Auth::id())
->where('domain', (string) $record['domain'])
->delete();
}
// Delete screenshot if exists
$screenshotPath = storage_path('app/public/screenshots/wp-'.$record['id'].'.png');
if (file_exists($screenshotPath)) {
@@ -894,22 +937,75 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
}
}
public function createStaging(string $siteId, string $subdomain): void
public function createStaging(string $siteId, string $subdomain, string $targetDomain = '', string $targetType = 'subdomain'): void
{
try {
$sourceSite = collect($this->sites)->firstWhere('id', $siteId);
$sourceDomain = (string) ($sourceSite['domain'] ?? '');
$normalizedSourceDomain = strtolower(trim($sourceDomain));
$agentPayload = [
'username' => $this->getUsername(),
'site_id' => $siteId,
];
if ($targetType === 'domain') {
$targetDomain = strtolower(trim($targetDomain));
if ($targetDomain === '') {
throw new Exception(__('Please choose a target domain.'));
}
if ($targetDomain === $normalizedSourceDomain) {
throw new Exception(__('The staging domain must be different from the source domain.'));
}
if (! $this->isOwnedDomain($targetDomain)) {
throw new Exception(__('The selected domain is not in your domain list.'));
}
$agentPayload['target_domain'] = $targetDomain;
} else {
$subdomain = strtolower(trim($subdomain));
if ($subdomain === '') {
throw new Exception(__('Please enter a subdomain.'));
}
if (! preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $subdomain)) {
throw new Exception(__('Subdomain can contain only letters, numbers, and hyphens.'));
}
$agentPayload['subdomain'] = $subdomain;
}
Notification::make()
->title(__('Creating Staging Environment...'))
->body(__('This may take several minutes.'))
->info()
->send();
$result = $this->getAgent()->send('wp.create_staging', [
'username' => $this->getUsername(),
'site_id' => $siteId,
'subdomain' => 'staging-'.$subdomain,
]);
$result = $this->getAgent()->send('wp.create_staging', $agentPayload);
if ($result['success'] ?? false) {
$stagingDomain = (string) ($result['staging_domain'] ?? '');
if ($stagingDomain !== '') {
Domain::firstOrCreate(
[
'user_id' => Auth::id(),
'domain' => $stagingDomain,
],
[
'document_root' => '/home/'.$this->getUsername().'/domains/'.$stagingDomain.'/public_html',
'is_active' => true,
'ssl_enabled' => false,
'directory_index' => 'index.php index.html',
'page_cache_enabled' => false,
]
);
}
if (
$sourceDomain !== ''
&& $stagingDomain !== ''
&& str_ends_with(strtolower($stagingDomain), '.'.strtolower($sourceDomain))
) {
$this->ensureStagingDnsRecords($sourceDomain, $stagingDomain);
}
Notification::make()
->title(__('Staging Environment Created'))
->body(__('Your staging site is available at: :url', ['url' => $result['staging_url'] ?? '']))
@@ -1466,4 +1562,193 @@ class WordPress extends Page implements HasActions, HasForms, HasTable
return file_exists(storage_path('app/public/screenshots/'.$filename));
}
protected function ensureStagingDnsRecords(string $sourceDomainName, string $stagingDomainName): void
{
$sourceDomain = Domain::query()
->where('user_id', Auth::id())
->where('domain', $sourceDomainName)
->first();
if (! $sourceDomain) {
return;
}
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
if ($label === null || $label === '') {
return;
}
$settings = DnsSetting::getAll();
$defaultTtl = (int) ($settings['default_ttl'] ?? 3600);
$defaultIpv4 = $sourceDomain->ip_address
?: ($settings['default_ip'] ?? trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? '')) ?: '127.0.0.1');
DnsRecord::query()->updateOrCreate(
[
'domain_id' => $sourceDomain->id,
'name' => $label,
'type' => 'A',
],
[
'content' => $defaultIpv4,
'ttl' => $defaultTtl,
'priority' => null,
]
);
$defaultIpv6 = $sourceDomain->ipv6_address ?: ($settings['default_ipv6'] ?? null);
if (! empty($defaultIpv6)) {
DnsRecord::query()->updateOrCreate(
[
'domain_id' => $sourceDomain->id,
'name' => $label,
'type' => 'AAAA',
],
[
'content' => $defaultIpv6,
'ttl' => $defaultTtl,
'priority' => null,
]
);
}
try {
$this->syncDnsZone($sourceDomain, $settings);
} catch (Exception) {
// Keep staging creation successful even if DNS sync is temporarily unavailable.
}
}
protected function removeStagingDnsRecords(array $stagingSite): void
{
$stagingDomainName = strtolower(trim((string) ($stagingSite['domain'] ?? '')));
if ($stagingDomainName === '') {
return;
}
$sourceDomainName = '';
$sourceSiteId = $stagingSite['source_site_id'] ?? null;
if (is_string($sourceSiteId) && $sourceSiteId !== '') {
$sourceSite = collect($this->sites)->firstWhere('id', $sourceSiteId);
$sourceDomainName = strtolower(trim((string) ($sourceSite['domain'] ?? '')));
}
if ($sourceDomainName === '') {
$parts = explode('.', $stagingDomainName, 2);
if (count($parts) === 2) {
$sourceDomainName = $parts[1];
}
}
if ($sourceDomainName === '') {
return;
}
$sourceDomain = Domain::query()
->where('user_id', Auth::id())
->where('domain', $sourceDomainName)
->first();
if (! $sourceDomain) {
return;
}
$label = $this->extractSubdomainLabel($stagingDomainName, $sourceDomainName);
if ($label === null || $label === '') {
return;
}
DnsRecord::query()
->where('domain_id', $sourceDomain->id)
->where('name', $label)
->whereIn('type', ['A', 'AAAA'])
->delete();
try {
$this->syncDnsZone($sourceDomain);
} catch (Exception) {
// Keep deletion successful even if DNS sync is temporarily unavailable.
}
}
protected function syncDnsZone(Domain $domain, ?array $settings = null): void
{
$settings ??= DnsSetting::getAll();
$records = DnsRecord::query()->where('domain_id', $domain->id)->get()->toArray();
$hostname = gethostname() ?: 'localhost';
$serverIp = trim((string) (shell_exec("hostname -I | awk '{print $1}'") ?? ''));
$this->getAgent()->dnsSyncZone($domain->domain, $records, [
'ns1' => $settings['ns1'] ?? "ns1.{$hostname}",
'ns2' => $settings['ns2'] ?? "ns2.{$hostname}",
'admin_email' => $settings['admin_email'] ?? "admin.{$hostname}",
'default_ip' => $settings['default_ip'] ?? ($serverIp !== '' ? $serverIp : '127.0.0.1'),
'default_ipv6' => $settings['default_ipv6'] ?? null,
'default_ttl' => $settings['default_ttl'] ?? 3600,
]);
}
protected function extractSubdomainLabel(string $fullDomain, string $baseDomain): ?string
{
$fullDomain = strtolower(trim($fullDomain, " \t\n\r\0\x0B."));
$baseDomain = strtolower(trim($baseDomain, " \t\n\r\0\x0B."));
if ($fullDomain === '' || $baseDomain === '') {
return null;
}
if ($fullDomain === $baseDomain) {
return null;
}
$suffix = '.'.$baseDomain;
if (! str_ends_with($fullDomain, $suffix)) {
return null;
}
$label = substr($fullDomain, 0, -strlen($suffix));
return trim((string) $label, '.');
}
protected function getOwnedDomainOptions(array $exclude = []): array
{
$excludeSet = [];
foreach ($exclude as $value) {
$normalized = strtolower(trim((string) $value));
if ($normalized !== '') {
$excludeSet[$normalized] = true;
}
}
$options = [];
foreach ($this->domains as $domain) {
$name = strtolower(trim((string) ($domain['domain'] ?? '')));
if ($name === '' || isset($excludeSet[$name])) {
continue;
}
$options[$name] = $name;
}
return $options;
}
protected function isOwnedDomain(string $domain): bool
{
$domain = strtolower(trim($domain));
if ($domain === '') {
return false;
}
foreach ($this->domains as $ownedDomain) {
$candidate = strtolower(trim((string) ($ownedDomain['domain'] ?? '')));
if ($candidate === $domain) {
return true;
}
}
return false;
}
}

View File

@@ -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

@@ -164,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

View File

@@ -7914,6 +7914,8 @@ function wpDelete(array $params): array
}
$site = $wpSites[$siteId];
$isStagingSite = (bool) ($site['is_staging'] ?? false);
$siteDomain = strtolower(trim((string) ($site['domain'] ?? '')));
// Delete database if requested
$dbDeleted = false;
@@ -7943,8 +7945,37 @@ function wpDelete(array $params): array
}
}
// Delete files if requested
if ($deleteFiles && !empty($site['install_path'])) {
if ($isStagingSite && $siteDomain !== '') {
$vhostFile = "/etc/nginx/sites-available/{$siteDomain}.conf";
if (file_exists("/etc/nginx/sites-enabled/{$siteDomain}.conf")) {
@unlink("/etc/nginx/sites-enabled/{$siteDomain}.conf");
}
if (file_exists($vhostFile)) {
@unlink($vhostFile);
}
if ($deleteFiles) {
$stagingDomainRoot = "{$userHome}/domains/{$siteDomain}";
if (is_dir($stagingDomainRoot)) {
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
}
}
$domainListFile = "{$userHome}/.domains";
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
unset($domains[$siteDomain]);
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
@chown($domainListFile, $userInfo['uid']);
@chgrp($domainListFile, $userInfo['gid']);
}
exec("nginx -t 2>&1", $nginxTestOutput, $nginxTestCode);
if ($nginxTestCode === 0) {
exec("systemctl reload nginx 2>&1");
}
} elseif ($deleteFiles && !empty($site['install_path'])) {
$installPath = $site['install_path'];
// Safety check - make sure it's within user's domain folder
if (strpos($installPath, "{$userHome}/domains/") === 0) {
@@ -7991,8 +8022,50 @@ function wpAutoLogin(array $params): array
}
$site = $wpSites[$siteId];
$installPath = $site['install_path'];
$adminUser = $site['admin_user'];
$installPath = $site['install_path'] ?? '';
if ($installPath === '' || !is_dir($installPath)) {
return ['success' => false, 'error' => 'WordPress installation path not found'];
}
$adminUser = trim((string) ($site['admin_user'] ?? ''));
if ($adminUser === '') {
exec(
"cd " . escapeshellarg($installPath) . " && sudo -u " . escapeshellarg($username) . " wp user list --role=administrator --field=user_login --format=csv 2>&1",
$adminOutput,
$adminCode
);
if ($adminCode === 0) {
$adminLines = array_values(array_filter(array_map('trim', $adminOutput), fn ($line) => $line !== '' && $line !== 'user_login'));
if ($adminLines !== []) {
$adminUser = (string) $adminLines[0];
}
}
}
if ($adminUser === '') {
return ['success' => false, 'error' => 'Could not determine WordPress admin user'];
}
// Persist a recovered admin username so future auto-login calls are fast and stable.
if (($site['admin_user'] ?? '') !== $adminUser) {
$wpSites[$siteId]['admin_user'] = $adminUser;
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
@chown($wpListFile, $userInfo['uid']);
@chgrp($wpListFile, $userInfo['gid']);
}
$baseUrl = trim((string) ($site['url'] ?? ''));
if ($baseUrl === '') {
$domain = trim((string) ($site['domain'] ?? ''));
if ($domain !== '') {
$baseUrl = 'https://' . $domain;
}
}
if ($baseUrl === '') {
return ['success' => false, 'error' => 'Could not determine site URL'];
}
// Skip WP-CLI login package (slow) - use direct fallback method
// Generate secure auto-login token
@@ -8002,7 +8075,7 @@ function wpAutoLogin(array $params): array
$token = bin2hex(random_bytes(32));
$expiry = time() + 300; // 5 minute expiry
$adminUrl = $site['url'] . '/wp-admin/';
$adminUrl = rtrim($baseUrl, '/') . '/wp-admin/';
$autoLoginContent = '<?php
// Auto-login script - expires after one use or 5 minutes
$token = "' . $token . '";
@@ -8036,7 +8109,7 @@ die("User not found.");
exec("sudo -u " . escapeshellarg($username) . " chmod 644 " . escapeshellarg($autoLoginFile));
@unlink($tempFile);
$loginUrl = $site['url'] . "/jabali-auto-login-{$token}.php?token={$token}";
$loginUrl = rtrim($baseUrl, '/') . "/jabali-auto-login-{$token}.php?token={$token}";
}
return ['success' => true, 'login_url' => $loginUrl];
@@ -8265,7 +8338,8 @@ function wpCreateStaging(array $params): array
{
$username = $params['username'] ?? '';
$siteId = $params['site_id'] ?? '';
$subdomain = $params['subdomain'] ?? 'staging';
$subdomain = strtolower(trim((string) ($params['subdomain'] ?? 'staging')));
$targetDomain = strtolower(trim((string) ($params['target_domain'] ?? '')));
if (!validateUsername($username)) {
return ['success' => false, 'error' => 'Invalid username'];
@@ -8290,15 +8364,412 @@ function wpCreateStaging(array $params): array
$site = $wpSites[$siteId];
$sourcePath = $site['install_path'];
$sourceDomain = $site['domain'];
$sourceDomain = strtolower(trim((string) ($site['domain'] ?? '')));
if ($sourceDomain === '' || !validateDomain($sourceDomain)) {
return ['success' => false, 'error' => 'Source domain is invalid'];
}
// Create staging domain
// Resolve staging domain from either explicit target domain or subdomain label.
if ($targetDomain !== '') {
if (!validateDomain($targetDomain)) {
return ['success' => false, 'error' => 'Invalid target domain'];
}
if (!validateUserDomain($username, $targetDomain)) {
return ['success' => false, 'error' => 'Target domain does not belong to user'];
}
if ($targetDomain === $sourceDomain) {
return ['success' => false, 'error' => 'Target domain must be different from source domain'];
}
$stagingDomain = $targetDomain;
} else {
if ($subdomain === '') {
$subdomain = 'staging';
}
if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i', $subdomain) !== 1) {
return ['success' => false, 'error' => 'Invalid subdomain label'];
}
$stagingDomain = "{$subdomain}.{$sourceDomain}";
$stagingPath = "{$userHome}/domains/{$stagingDomain}/public_html";
}
// Check if staging already exists
if (is_dir($stagingPath)) {
return ['success' => false, 'error' => 'Staging site already exists at ' . $stagingDomain];
$stagingDomainRoot = "{$userHome}/domains/{$stagingDomain}";
$stagingPath = "{$userHome}/domains/{$stagingDomain}/public_html";
$stagingUrl = "https://{$stagingDomain}";
$syncStagingUrls = function (string $targetPath, string $targetUrl, string $originalUrl, string $originalDomain, string $targetDomain) use ($username): array {
$replacePairs = [];
$normalizedOriginalUrl = trim($originalUrl);
if ($normalizedOriginalUrl !== '' && $normalizedOriginalUrl !== $targetUrl) {
$replacePairs[$normalizedOriginalUrl] = $targetUrl;
}
$replacePairs["https://{$originalDomain}"] = "https://{$targetDomain}";
$replacePairs["http://{$originalDomain}"] = "http://{$targetDomain}";
// Avoid raw domain replacement because repeated syncs can duplicate subdomains
// (e.g. staging.example.com -> staging.staging.example.com).
foreach ($replacePairs as $search => $replace) {
if ($search === '' || $search === $replace) {
continue;
}
$replaceCmd = "cd " . escapeshellarg($targetPath)
. " && sudo -u " . escapeshellarg($username)
. " wp search-replace " . escapeshellarg($search)
. " " . escapeshellarg($replace)
. " --all-tables --skip-columns=guid --quiet 2>&1";
exec($replaceCmd, $replaceOutput, $replaceCode);
if ($replaceCode !== 0) {
return ['success' => false, 'error' => 'Failed to update staging URLs'];
}
}
// Repair any legacy duplicated-subdomain values created by previous sync logic.
$suffix = '.' . $originalDomain;
if (str_ends_with($targetDomain, $suffix)) {
$targetLabel = substr($targetDomain, 0, -strlen($suffix));
$targetLabel = trim((string) $targetLabel, '.');
if ($targetLabel !== '') {
$duplicateDomain = $targetLabel . '.' . $targetDomain;
if ($duplicateDomain !== $targetDomain) {
$cleanupPairs = [
"https://{$duplicateDomain}" => "https://{$targetDomain}",
"http://{$duplicateDomain}" => "http://{$targetDomain}",
$duplicateDomain => $targetDomain,
];
foreach ($cleanupPairs as $search => $replace) {
$cleanupCmd = "cd " . escapeshellarg($targetPath)
. " && sudo -u " . escapeshellarg($username)
. " wp search-replace " . escapeshellarg($search)
. " " . escapeshellarg($replace)
. " --all-tables --skip-columns=guid --quiet 2>&1";
exec($cleanupCmd, $cleanupOutput, $cleanupCode);
if ($cleanupCode !== 0) {
return ['success' => false, 'error' => 'Failed to clean duplicated staging URLs'];
}
}
}
}
}
$updateHomeCmd = "cd " . escapeshellarg($targetPath)
. " && sudo -u " . escapeshellarg($username)
. " wp option update home " . escapeshellarg($targetUrl) . " --quiet 2>&1";
exec($updateHomeCmd, $updateHomeOutput, $updateHomeCode);
if ($updateHomeCode !== 0) {
return ['success' => false, 'error' => 'Failed to set staging home URL'];
}
$updateSiteUrlCmd = "cd " . escapeshellarg($targetPath)
. " && sudo -u " . escapeshellarg($username)
. " wp option update siteurl " . escapeshellarg($targetUrl) . " --quiet 2>&1";
exec($updateSiteUrlCmd, $updateSiteUrlOutput, $updateSiteUrlCode);
if ($updateSiteUrlCode !== 0) {
return ['success' => false, 'error' => 'Failed to set staging site URL'];
}
return ['success' => true];
};
$refreshStagingDatabase = function (string $sourceDatabase, string $targetDatabase) use ($siteId): array {
if ($sourceDatabase === '' || $targetDatabase === '') {
return ['success' => false, 'error' => 'Missing source or target database for staging refresh'];
}
$dumpFile = "/tmp/wp_staging_refresh_{$siteId}_" . bin2hex(random_bytes(4)) . ".sql";
exec(
"mysqldump --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($sourceDatabase) . " > " . escapeshellarg($dumpFile) . " 2>&1",
$dumpOutput,
$dumpCode
);
if ($dumpCode !== 0) {
@unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to export source database for staging refresh'];
}
$dropSql = "DROP DATABASE IF EXISTS `" . addslashes($targetDatabase) . "`";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($dropSql) . " 2>&1", $dropOutput, $dropCode);
if ($dropCode !== 0) {
@unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to reset staging database'];
}
$createSql = "CREATE DATABASE `" . addslashes($targetDatabase) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createSql) . " 2>&1", $createOutput, $createCode);
if ($createCode !== 0) {
@unlink($dumpFile);
return ['success' => false, 'error' => 'Failed to recreate staging database'];
}
exec(
"mysql --defaults-file=/etc/mysql/debian.cnf " . escapeshellarg($targetDatabase) . " < " . escapeshellarg($dumpFile) . " 2>&1",
$importOutput,
$importCode
);
@unlink($dumpFile);
if ($importCode !== 0) {
return ['success' => false, 'error' => 'Failed to import refreshed staging database'];
}
return ['success' => true];
};
$syncStagingCacheIsolation = function (string $targetPath, string $targetDomain) use ($username, $userInfo): array {
$wpConfigPath = rtrim($targetPath, '/') . '/wp-config.php';
$wpConfig = @file_get_contents($wpConfigPath);
if (!is_string($wpConfig) || $wpConfig === '') {
return ['success' => false, 'error' => 'Failed to read staging wp-config.php for cache isolation'];
}
$domainToken = strtolower($targetDomain);
$domainToken = preg_replace('/[^a-z0-9_]+/', '_', $domainToken);
$domainToken = trim((string) $domainToken, '_');
if ($domainToken === '') {
$domainToken = 'staging_site';
}
$cachePrefix = 'jc_' . substr($domainToken, 0, 44) . '_';
$cacheSalt = 'jabali_stage_' . substr(hash('sha256', $targetDomain . '|' . $targetPath), 0, 40);
$setConfigConstant = function (string $constantName, string $constantValue) use (&$wpConfig): bool {
$escapedValue = str_replace(['\\', '\''], ['\\\\', '\\\''], $constantValue);
$replacement = "define('{$constantName}', '{$escapedValue}');";
$pattern = "/define\\s*\\(\\s*['\\\"]" . preg_quote($constantName, '/') . "['\\\"]\\s*,\\s*.+?\\)\\s*;/is";
if (preg_match($pattern, $wpConfig) === 1) {
$wpConfig = preg_replace($pattern, $replacement, $wpConfig, 1, $count);
return $count > 0;
}
if (preg_match('/^<\\?php\\s*/i', $wpConfig) === 1) {
$wpConfig = preg_replace('/^<\\?php\\s*/i', "<?php\n{$replacement}\n", $wpConfig, 1);
return true;
}
return false;
};
$saltSet = $setConfigConstant('WP_CACHE_KEY_SALT', $cacheSalt);
$prefixSet = $setConfigConstant('JABALI_CACHE_PREFIX', $cachePrefix);
if (!$saltSet || !$prefixSet) {
return ['success' => false, 'error' => 'Failed to set staging cache constants in wp-config.php'];
}
if (@file_put_contents($wpConfigPath, $wpConfig) === false) {
return ['success' => false, 'error' => 'Failed to write staging cache constants to wp-config.php'];
}
@chown($wpConfigPath, $userInfo['uid']);
@chgrp($wpConfigPath, $userInfo['gid']);
$flushCmd = "cd " . escapeshellarg($targetPath)
. " && sudo -u " . escapeshellarg($username)
. " wp cache flush 2>&1";
exec($flushCmd, $flushOutput, $flushCode);
if ($flushCode !== 0) {
// Cache flush can fail when no object-cache drop-in is active.
// Keep migration flow successful as constants are already isolated.
}
return [
'success' => true,
'cache_prefix' => $cachePrefix,
];
};
// Check if staging is already tracked (idempotent for repeated create requests).
// Protect existing non-staging sites from accidental overwrite.
$existingTrackedId = null;
$existingTrackedSite = null;
foreach ($wpSites as $candidateId => $existingSite) {
$candidateDomain = strtolower(trim((string) ($existingSite['domain'] ?? '')));
if ($candidateDomain !== $stagingDomain) {
continue;
}
$candidateIsStaging = (bool) ($existingSite['is_staging'] ?? false);
$candidateSourceSiteId = (string) ($existingSite['source_site_id'] ?? '');
if ($candidateIsStaging && $candidateSourceSiteId === $siteId) {
$existingTrackedId = (string) $candidateId;
$existingTrackedSite = is_array($existingSite) ? $existingSite : null;
break;
}
return ['success' => false, 'error' => 'Target domain already used by another WordPress site'];
}
if ($existingTrackedId !== null && is_array($existingTrackedSite)) {
$existingInstallPath = trim((string) ($existingTrackedSite['install_path'] ?? ''));
$existingUrl = trim((string) ($existingTrackedSite['url'] ?? ''));
if ($existingInstallPath !== '' && is_dir($existingInstallPath)) {
$trackedDbName = trim((string) ($existingTrackedSite['db_name'] ?? ''));
$trackedDbUser = trim((string) ($existingTrackedSite['db_user'] ?? ''));
$sourceDbNameForRefresh = '';
$sourceDbUserForRefresh = '';
$sourceWpConfigPath = rtrim((string) $sourcePath, '/') . '/wp-config.php';
$sourceWpConfig = @file_get_contents($sourceWpConfigPath);
if (is_string($sourceWpConfig) && $sourceWpConfig !== '') {
preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbNameMatch);
preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $sourceWpConfig, $sourceDbUserMatch);
$sourceDbNameForRefresh = $sourceDbNameMatch[1] ?? '';
$sourceDbUserForRefresh = $sourceDbUserMatch[1] ?? '';
}
if ($sourceDbNameForRefresh === '') {
$sourceDbNameForRefresh = trim((string) ($site['db_name'] ?? ''));
}
if ($sourceDbUserForRefresh === '') {
$sourceDbUserForRefresh = trim((string) ($site['db_user'] ?? ''));
}
if ($sourceDbUserForRefresh === '') {
$sourceDbUserForRefresh = $sourceDbNameForRefresh;
}
if ($sourceDbNameForRefresh === '') {
return ['success' => false, 'error' => 'Could not read source database name for staging refresh'];
}
$trackedDbNameLower = strtolower($trackedDbName);
$trackedDbUserLower = strtolower($trackedDbUser);
$sourceDbNameLower = strtolower($sourceDbNameForRefresh);
$sourceDbUserLower = strtolower($sourceDbUserForRefresh);
// Old staging entries may still reuse source DB credentials.
// Remove those tracked entries and recreate from scratch with isolated DB creds.
if (
$trackedDbName === ''
|| $trackedDbUser === ''
|| $trackedDbNameLower === $sourceDbNameLower
|| $trackedDbUserLower === $sourceDbUserLower
) {
unset($wpSites[$existingTrackedId]);
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
$staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf";
if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) {
@unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf");
}
if (file_exists($staleVhost)) {
@unlink($staleVhost);
}
if (is_dir($stagingDomainRoot)) {
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
}
} else {
if ($trackedDbName !== '' && $trackedDbUser !== '') {
$existingWpConfigPath = rtrim($existingInstallPath, '/') . '/wp-config.php';
$existingWpConfig = @file_get_contents($existingWpConfigPath);
$configDbName = '';
$configDbUser = '';
if (is_string($existingWpConfig) && $existingWpConfig !== '') {
preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbNameMatch);
preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]([^'\\\"]+)['\\\"]\\s*\\)/", $existingWpConfig, $existingDbUserMatch);
$configDbName = $existingDbNameMatch[1] ?? '';
$configDbUser = $existingDbUserMatch[1] ?? '';
}
// Self-heal old staging records where wp-config drifted from tracked DB credentials.
if ($configDbName !== $trackedDbName || $configDbUser !== $trackedDbUser) {
$repairedDbPass = bin2hex(random_bytes(12));
$createUserSql = "CREATE USER IF NOT EXISTS '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($createUserSql) . " 2>&1", $repairUserOutput, $repairUserCode);
if ($repairUserCode !== 0) {
return ['success' => false, 'error' => 'Failed to repair staging database user'];
}
$alterUserSql = "ALTER USER '" . addslashes($trackedDbUser) . "'@'localhost' IDENTIFIED BY '" . addslashes($repairedDbPass) . "'";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($alterUserSql) . " 2>&1", $repairAlterOutput, $repairAlterCode);
if ($repairAlterCode !== 0) {
return ['success' => false, 'error' => 'Failed to reset staging database user password'];
}
$grantSql = "GRANT ALL PRIVILEGES ON `" . addslashes($trackedDbName) . "`.* TO '" . addslashes($trackedDbUser) . "'@'localhost'";
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg($grantSql) . " 2>&1", $repairGrantOutput, $repairGrantCode);
if ($repairGrantCode !== 0) {
return ['success' => false, 'error' => 'Failed to repair staging database grants'];
}
exec("mysql --defaults-file=/etc/mysql/debian.cnf -e " . escapeshellarg("FLUSH PRIVILEGES") . " 2>&1");
$setExistingDbNameCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_NAME " . escapeshellarg($trackedDbName) . " --type=constant --quiet 2>&1";
exec($setExistingDbNameCmd);
$setExistingDbUserCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_USER " . escapeshellarg($trackedDbUser) . " --type=constant --quiet 2>&1";
exec($setExistingDbUserCmd);
$setExistingDbPassCmd = "cd " . escapeshellarg($existingInstallPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_PASSWORD " . escapeshellarg($repairedDbPass) . " --type=constant --quiet 2>&1";
exec($setExistingDbPassCmd);
$existingWpConfig = (string) @file_get_contents($existingWpConfigPath);
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_NAME', '{$trackedDbName}');", $existingWpConfig, 1);
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_USER', '{$trackedDbUser}');", $existingWpConfig, 1);
$existingWpConfig = preg_replace("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is", "define('DB_PASSWORD', '{$repairedDbPass}');", $existingWpConfig, 1);
file_put_contents($existingWpConfigPath, $existingWpConfig);
@chown($existingWpConfigPath, $userInfo['uid']);
@chgrp($existingWpConfigPath, $userInfo['gid']);
}
}
if ($trackedDbName !== '') {
$refreshResult = $refreshStagingDatabase($sourceDbNameForRefresh, $trackedDbName);
if (!($refreshResult['success'] ?? false)) {
return ['success' => false, 'error' => $refreshResult['error'] ?? 'Failed to refresh staging database'];
}
}
$urlSyncResult = $syncStagingUrls(
$existingInstallPath,
$stagingUrl,
(string) ($site['url'] ?? ''),
$sourceDomain,
$stagingDomain
);
if (!($urlSyncResult['success'] ?? false)) {
return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs'];
}
$cacheSyncResult = $syncStagingCacheIsolation($existingInstallPath, $stagingDomain);
if (!($cacheSyncResult['success'] ?? false)) {
return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings'];
}
$wpSites[$existingTrackedId]['cache_prefix'] = (string) ($cacheSyncResult['cache_prefix'] ?? '');
if (($site['cache_enabled'] ?? false) || file_exists($existingInstallPath . '/wp-content/object-cache.php')) {
$wpSites[$existingTrackedId]['cache_enabled'] = true;
}
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
$existingUrl = $stagingUrl;
return [
'success' => true,
'staging_url' => $existingUrl,
'staging_domain' => $stagingDomain,
'staging_site_id' => $existingTrackedId,
];
}
}
// Tracked entry is stale (path missing). Remove it and continue with fresh creation.
unset($wpSites[$existingTrackedId]);
file_put_contents($wpListFile, json_encode($wpSites, JSON_PRETTY_PRINT));
}
// Check for stale filesystem leftovers and self-heal before creating.
if (is_dir($stagingPath) || is_dir($stagingDomainRoot)) {
$staleVhost = "/etc/nginx/sites-available/{$stagingDomain}.conf";
if (file_exists("/etc/nginx/sites-enabled/{$stagingDomain}.conf")) {
@unlink("/etc/nginx/sites-enabled/{$stagingDomain}.conf");
}
if (file_exists($staleVhost)) {
@unlink($staleVhost);
}
exec("rm -rf " . escapeshellarg($stagingDomainRoot));
}
// Create staging directories
@@ -8346,19 +8817,53 @@ function wpCreateStaging(array $params): array
$sourceDbUser = $dbUserMatch[1] ?? '';
$sourceDbPass = $dbPassMatch[1] ?? '';
// Fallback to tracked metadata if wp-config uses non-string expressions.
if ($sourceDbName === '') {
$sourceDbName = (string) ($site['db_name'] ?? '');
}
if ($sourceDbUser === '') {
$sourceDbUser = (string) ($site['db_user'] ?? '');
}
if ($sourceDbUser === '') {
$sourceDbUser = $sourceDbName;
}
if (empty($sourceDbName)) {
return ['success' => false, 'error' => 'Could not read source database name'];
}
if (empty($sourceDbUser)) {
return ['success' => false, 'error' => 'Could not read source database user'];
}
// Create staging database and user
$suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $subdomain);
// Create staging database and user.
// Keep suffix preserved so names cannot truncate back to source credentials.
$suffixSeed = $targetDomain !== '' ? $stagingDomain : $subdomain;
$suffix = preg_replace('/[^a-zA-Z0-9_]+/', '_', $suffixSeed);
$suffix = trim((string) $suffix, '_');
if ($suffix === '') {
$suffix = 'staging';
}
$stagingDbName = substr($sourceDbName . '_' . $suffix . '_stg', 0, 64);
$stagingDbUser = substr($sourceDbUser . '_' . $suffix . '_stg', 0, 32);
$buildStagingIdentifier = function (string $source, string $suffixValue, int $maxLength): string {
$token = '_' . $suffixValue . '_stg';
if (strlen($token) >= $maxLength) {
$token = '_' . substr(sha1($suffixValue), 0, max(6, $maxLength - 2));
}
$baseMaxLength = max(1, $maxLength - strlen($token));
$base = substr($source, 0, $baseMaxLength);
return $base . $token;
};
$stagingDbName = $buildStagingIdentifier($sourceDbName, $suffix, 64);
$stagingDbUser = $buildStagingIdentifier($sourceDbUser, $suffix, 32);
if ($stagingDbName === $sourceDbName) {
$stagingDbName = substr($sourceDbName, 0, 54) . '_stg_' . substr(sha1($suffix), 0, 5);
}
if ($stagingDbUser === $sourceDbUser) {
$stagingDbUser = substr($sourceDbUser, 0, 22) . '_stg_' . substr(sha1($suffix), 0, 5);
}
$stagingDbPass = bin2hex(random_bytes(12));
// Export and import database
@@ -8401,32 +8906,89 @@ function wpCreateStaging(array $params): array
return ['success' => false, 'error' => 'Failed to import database'];
}
// Update wp-config.php with new database credentials
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_NAME['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
"define('DB_NAME', '{$stagingDbName}')",
$wpConfig
);
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_USER['\"]\s*,\s*['\"][^'\"]+['\"]\s*\)/",
"define('DB_USER', '{$stagingDbUser}')",
$wpConfig
);
$wpConfig = preg_replace(
"/define\s*\(\s*['\"]DB_PASSWORD['\"]\s*,\s*['\"][^'\"]*['\"]\s*\)/",
"define('DB_PASSWORD', '{$stagingDbPass}')",
$wpConfig
);
file_put_contents($wpConfigPath, $wpConfig);
// Force-update wp-config.php with staging database credentials.
// First try WP-CLI config set (handles many formatting variants), then regex fallback.
$setDbNameCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_NAME " . escapeshellarg($stagingDbName) . " --type=constant --quiet 2>&1";
exec($setDbNameCmd, $setDbNameOut, $setDbNameCode);
// Update URLs in staging database
$stagingUrl = "https://{$stagingDomain}";
$sourceUrl = $site['url'];
$setDbUserCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_USER " . escapeshellarg($stagingDbUser) . " --type=constant --quiet 2>&1";
exec($setDbUserCmd, $setDbUserOut, $setDbUserCode);
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceUrl) . " " . escapeshellarg($stagingUrl) . " --all-tables 2>&1");
$setDbPassCmd = "cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username)
. " wp config set DB_PASSWORD " . escapeshellarg($stagingDbPass) . " --type=constant --quiet 2>&1";
exec($setDbPassCmd, $setDbPassOut, $setDbPassCode);
// Also replace without protocol
exec("cd " . escapeshellarg($stagingPath) . " && sudo -u " . escapeshellarg($username) . " wp search-replace " . escapeshellarg($sourceDomain) . " " . escapeshellarg($stagingDomain) . " --all-tables 2>&1");
$wpConfigAfterSet = @file_get_contents($wpConfigPath);
if (!is_string($wpConfigAfterSet) || $wpConfigAfterSet === '') {
return ['success' => false, 'error' => 'Failed to read staging wp-config.php after DB update'];
}
$nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
$userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
$passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
if (!$nameUpdated || !$userUpdated || !$passUpdated) {
$nameCount = 0;
$userCount = 0;
$passCount = 0;
$wpConfigAfterSet = preg_replace(
"/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
"define('DB_NAME', '{$stagingDbName}');",
$wpConfigAfterSet,
1,
$nameCount
);
$wpConfigAfterSet = preg_replace(
"/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
"define('DB_USER', '{$stagingDbUser}');",
$wpConfigAfterSet,
1,
$userCount
);
$wpConfigAfterSet = preg_replace(
"/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*.+?\\)\\s*;/is",
"define('DB_PASSWORD', '{$stagingDbPass}');",
$wpConfigAfterSet,
1,
$passCount
);
if ($nameCount === 0 || $userCount === 0 || $passCount === 0) {
return ['success' => false, 'error' => 'Failed to update staging wp-config.php database constants'];
}
file_put_contents($wpConfigPath, $wpConfigAfterSet);
@chown($wpConfigPath, $userInfo['uid']);
@chgrp($wpConfigPath, $userInfo['gid']);
$wpConfigAfterSet = (string) @file_get_contents($wpConfigPath);
$nameUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_NAME['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbName, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
$userUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_USER['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbUser, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
$passUpdated = preg_match("/define\\s*\\(\\s*['\\\"]DB_PASSWORD['\\\"]\\s*,\\s*['\\\"]" . preg_quote($stagingDbPass, "/") . "['\\\"]\\s*\\)/", $wpConfigAfterSet) === 1;
}
if (!$nameUpdated || !$userUpdated || !$passUpdated) {
return ['success' => false, 'error' => 'Staging wp-config.php database credentials verification failed'];
}
$urlSyncResult = $syncStagingUrls(
$stagingPath,
$stagingUrl,
(string) ($site['url'] ?? ''),
$sourceDomain,
$stagingDomain
);
if (!($urlSyncResult['success'] ?? false)) {
return ['success' => false, 'error' => $urlSyncResult['error'] ?? 'Failed to sync staging URLs'];
}
$cacheSyncResult = $syncStagingCacheIsolation($stagingPath, $stagingDomain);
if (!($cacheSyncResult['success'] ?? false)) {
return ['success' => false, 'error' => $cacheSyncResult['error'] ?? 'Failed to isolate staging cache settings'];
}
// Create Nginx config for staging (includes HTTPS with snakeoil cert)
createFpmPool($username, false);
@@ -8441,6 +9003,21 @@ function wpCreateStaging(array $params): array
// Reload Nginx
exec("systemctl reload nginx 2>&1");
// Store staging domain in user's domain registry so it appears in Domain Manager.
$domainListFile = "{$userHome}/.domains";
$domains = [];
if (file_exists($domainListFile)) {
$domains = json_decode(file_get_contents($domainListFile), true) ?: [];
}
$domains[$stagingDomain] = [
'created' => date('Y-m-d H:i:s'),
'document_root' => $stagingPath,
'ssl' => false,
];
file_put_contents($domainListFile, json_encode($domains, JSON_PRETTY_PRINT));
@chown($domainListFile, $userInfo['uid']);
@chgrp($domainListFile, $userInfo['gid']);
// Add staging site to WordPress sites list
$stagingSiteId = 'staging_' . $siteId . '_' . time();
$wpSites[$stagingSiteId] = [
@@ -8448,10 +9025,14 @@ function wpCreateStaging(array $params): array
'domain' => $stagingDomain,
'path' => '',
'url' => $stagingUrl,
'admin_user' => (string) ($site['admin_user'] ?? 'admin'),
'admin_email' => (string) ($site['admin_email'] ?? ''),
'install_path' => $stagingPath,
'db_name' => $stagingDbName,
'db_user' => $stagingDbUser,
'version' => $site['version'] ?? 'Unknown',
'cache_enabled' => (bool) (($site['cache_enabled'] ?? false) || file_exists($stagingPath . '/wp-content/object-cache.php')),
'cache_prefix' => (string) ($cacheSyncResult['cache_prefix'] ?? ''),
'is_staging' => true,
'source_site_id' => $siteId,
'created_at' => date('Y-m-d H:i:s'),
@@ -12475,6 +13056,37 @@ function importDiscover(array $params): array
}
}
/**
* Whether insecure TLS is allowed for remote import discovery APIs.
*/
function allowInsecureImportTls(): bool
{
$value = strtolower(trim((string) getenv('JABALI_IMPORT_INSECURE_TLS')));
return in_array($value, ['1', 'true', 'yes', 'on'], true);
}
/**
* Configure secure cURL defaults for remote control panel discovery calls.
*/
function configureImportApiCurl($ch): void
{
if (allowInsecureImportTls()) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
return;
}
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
$caBundle = '/etc/ssl/certs/ca-certificates.crt';
if (is_readable($caBundle)) {
curl_setopt($ch, CURLOPT_CAINFO, $caBundle);
}
}
/**
* Discover accounts from a cPanel backup file
*/
@@ -12861,8 +13473,7 @@ function discoverCpanelRemote(string $host, int $port, string $user, string $pas
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
configureImportApiCurl($ch);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: WHM ' . $user . ':' . $password,
@@ -12921,8 +13532,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $detailUrl);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
configureImportApiCurl($ch);
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
@@ -12955,8 +13565,7 @@ function discoverDirectAdminRemote(string $host, int $port, string $user, string
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
configureImportApiCurl($ch);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
curl_setopt($ch, CURLOPT_USERPWD, "$user:$password");
@@ -20077,6 +20686,34 @@ function quotaGet(array $params): array
return ['success' => false, 'error' => 'Username is required'];
}
// Root-level disk usage probe for /home/<user>.
$getDuUsageMb = static function (string $user): float {
$homeDir = "/home/{$user}";
if (!is_dir($homeDir)) {
return 0.0;
}
$duOutput = trim(shell_exec("du -sk " . escapeshellarg($homeDir) . " 2>/dev/null | cut -f1") ?? '');
if ($duOutput === '' || preg_match('/^\d+$/', $duOutput) !== 1) {
return 0.0;
}
return round(((int) $duOutput) / 1024, 2);
};
// Quota counters can be stale on some filesystems; prefer the larger value.
$normalizeUsage = static function (float $usedMb, float $softMb, float $hardMb, float $duMb): array {
$finalUsedMb = $duMb > $usedMb ? $duMb : $usedMb;
return [
'used_mb' => $finalUsedMb,
'soft_mb' => $softMb,
'hard_mb' => $hardMb,
'usage_percent' => $hardMb > 0 ? round(($finalUsedMb / $hardMb) * 100, 1) : 0,
'quota_source' => $duMb > $usedMb ? 'du_override' : 'quota',
];
};
// Find the actual mount point
$findMount = trim(shell_exec("df --output=target " . escapeshellarg($mountPoint) . " 2>/dev/null | tail -1") ?? '');
if (empty($findMount)) {
@@ -20099,16 +20736,8 @@ function quotaGet(array $params): array
$repOutput = trim(shell_exec($cmd2) ?? '');
if (empty($repOutput)) {
// Quota not enabled - use du to calculate actual disk usage
$homeDir = "/home/{$username}";
$usedMb = 0;
if (is_dir($homeDir)) {
// Use du to get actual disk usage in KB
$duOutput = trim(shell_exec("du -sk " . escapeshellarg($homeDir) . " 2>/dev/null | cut -f1") ?? '0');
$usedKb = (int)$duOutput;
$usedMb = round($usedKb / 1024, 2);
}
// Quota not enabled - use du to calculate actual disk usage.
$usedMb = $getDuUsageMb($username);
return [
'success' => true,
@@ -20118,7 +20747,7 @@ function quotaGet(array $params): array
'soft_mb' => 0,
'hard_mb' => 0,
'usage_percent' => 0,
'quota_source' => 'du' // Indicate that we used du fallback
'quota_source' => 'du',
];
}
@@ -20126,18 +20755,21 @@ function quotaGet(array $params): array
// Format: username -- used soft hard grace used soft hard grace
$parts = preg_split('/\s+/', $repOutput);
if (count($parts) >= 5) {
$usedKb = (int)$parts[2];
$softKb = (int)$parts[3];
$hardKb = (int)$parts[4];
$usedMb = round(((int) $parts[2]) / 1024, 2);
$softMb = round(((int) $parts[3]) / 1024, 2);
$hardMb = round(((int) $parts[4]) / 1024, 2);
$duMb = $getDuUsageMb($username);
$usage = $normalizeUsage($usedMb, $softMb, $hardMb, $duMb);
return [
'success' => true,
'username' => $username,
'has_quota' => $softKb > 0 || $hardKb > 0,
'used_mb' => round($usedKb / 1024, 2),
'soft_mb' => round($softKb / 1024, 2),
'hard_mb' => round($hardKb / 1024, 2),
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
'has_quota' => $softMb > 0 || $hardMb > 0,
'used_mb' => $usage['used_mb'],
'soft_mb' => $usage['soft_mb'],
'hard_mb' => $usage['hard_mb'],
'usage_percent' => $usage['usage_percent'],
'quota_source' => $usage['quota_source'],
];
}
}
@@ -20157,14 +20789,21 @@ function quotaGet(array $params): array
}
}
$usedMb = round($usedKb / 1024, 2);
$softMb = round($softKb / 1024, 2);
$hardMb = round($hardKb / 1024, 2);
$duMb = $getDuUsageMb($username);
$usage = $normalizeUsage($usedMb, $softMb, $hardMb, $duMb);
return [
'success' => true,
'username' => $username,
'has_quota' => $softKb > 0 || $hardKb > 0,
'used_mb' => round($usedKb / 1024, 2),
'soft_mb' => round($softKb / 1024, 2),
'hard_mb' => round($hardKb / 1024, 2),
'usage_percent' => $hardKb > 0 ? round(($usedKb / $hardKb) * 100, 1) : 0
'has_quota' => $softMb > 0 || $hardMb > 0,
'used_mb' => $usage['used_mb'],
'soft_mb' => $usage['soft_mb'],
'hard_mb' => $usage['hard_mb'],
'usage_percent' => $usage['usage_percent'],
'quota_source' => $usage['quota_source'],
];
}

View File

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

119
composer.lock generated
View File

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

26
config.toml.example Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ Last updated: 2026-02-10
- /var/www/jabali/docs/installation.md - Debian package install path, Filament notifications patch, and deploy script usage.
- /var/www/jabali/docs/architecture/control-panel-blueprint.md - High-level blueprint for a hosting panel.
- /var/www/jabali/docs/architecture/directadmin-migration-blueprint.md - Blueprint for migrating DirectAdmin accounts into Jabali.
- /var/www/jabali/docs/architecture/mcp-and-filament-blueprint.md - Developer blueprint for MCP tooling and Filament panel conventions.
- /var/www/jabali/docs/archive-notes.md - Archived files and restore notes.
- /var/www/jabali/docs/screenshots/README.md - Screenshot generation instructions.
- /var/www/jabali/docs/docs-summary.md - Project documentation summary (generated).

View File

@@ -41,7 +41,7 @@ DNSSEC can be enabled per domain and generates KSK/ZSK keys, DS records, and sig
- Target OS: Fresh Debian 12/13 install with no pre-existing web/mail stack.
- Installer: install.sh, builds assets as www-data and ensures permissions.
- Upgrade: php artisan jabali:upgrade manages dependencies, caches, and permissions for public/build and node_modules.
- Deploy helper: scripts/deploy.sh syncs code to a server, runs composer/npm, migrations, and caches, and can push to Gitea/GitHub with automatic VERSION bump.
- Deploy helper: scripts/deploy.sh rsyncs to `root@192.168.100.50`, commits there, bumps VERSION, updates install.sh fallback, pushes to Git remotes from that server, then runs composer/npm, migrations, and caches.
## Packaging
Debian packaging is supported via scripts:

View File

@@ -112,11 +112,19 @@ browser (Ctrl+Shift+R) after deployment.
## Deploy script
The repository ships with a deploy helper at `scripts/deploy.sh`. It syncs the
project to a remote server over SSH, then runs composer/npm, migrations, and
cache rebuilds as the web user.
The repository ships with a deploy helper at `scripts/deploy.sh`. It rsyncs the
project to `root@192.168.100.50:/var/www/jabali`, commits on that server, bumps
`VERSION`, updates the `install.sh` fallback, and pushes to Git remotes from
that server. Then it runs composer/npm, migrations, and cache rebuilds.
Defaults (override via flags, env vars, or config.toml):
Config file:
- `config.toml` (ignored by git) is read automatically if present.
- Start from `config.toml.example`.
- Set `CONFIG_FILE` to use an alternate TOML file path.
- Supported keys are in `[deploy]` (for example: `host`, `user`, `path`, `www_user`, `push_branch`, `gitea_url`, `github_url`).
Defaults (override via flags or env vars):
- Host: `192.168.100.50`
- User: `root`
- Path: `/var/www/jabali`
@@ -137,20 +145,32 @@ scripts/deploy.sh --dry-run
scripts/deploy.sh --skip-npm --skip-cache
```
Push to Git remotes (optional):
Push behavior controls:
```
# Push to Gitea and/or GitHub before deploying
scripts/deploy.sh --push-gitea --push-github
# Deploy only (no push)
scripts/deploy.sh --skip-push
# Push to explicit URLs
scripts/deploy.sh --push-gitea --gitea-url http://192.168.100.100:3001/shukivaknin/jabali-panel.git \
--push-github --github-url git@github.com:shukiv/jabali-panel.git
# Push only one remote
scripts/deploy.sh --no-push-github
scripts/deploy.sh --no-push-gitea
# Push to explicit URLs from the test server
scripts/deploy.sh --gitea-url ssh://git@192.168.100.100:2222/shukivaknin/jabali-panel.git \
--github-url git@github.com:shukiv/jabali-panel.git
```
GitHub push location:
```
# Push to GitHub from the test server (required)
ssh root@192.168.100.50
cd /var/www/jabali
git push origin main
```
Notes:
- `--push-gitea` / `--push-github` require a clean worktree.
- When pushing, the script bumps `VERSION`, updates the `install.sh` fallback,
and commits the version bump before pushing.
- Pushes run from `root@192.168.100.50` (not from local machine).
- Before each push, the script bumps `VERSION`, updates `install.sh` fallback,
and commits on the deploy server.
- Rsync excludes `.env`, `storage/`, `vendor/`, `node_modules/`, `public/build/`,
`bootstrap/cache/`, and SQLite DB files. Handle those separately if needed.
- `--delete` passes `--delete` to rsync (dangerous).

View File

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

View File

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

12
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -3,26 +3,132 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEPLOY_HOST="${DEPLOY_HOST:-192.168.100.50}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/var/www/jabali}"
WWW_USER="${WWW_USER:-www-data}"
NPM_CACHE_DIR="${NPM_CACHE_DIR:-}"
GITEA_REMOTE="${GITEA_REMOTE:-gitea}"
GITEA_URL="${GITEA_URL:-}"
GITHUB_REMOTE="${GITHUB_REMOTE:-origin}"
GITHUB_URL="${GITHUB_URL:-}"
PUSH_BRANCH="${PUSH_BRANCH:-}"
CONFIG_FILE="${CONFIG_FILE:-$ROOT_DIR/config.toml}"
# Capture env overrides before we assign defaults so config.toml can sit between
# defaults and environment: CLI > env > config > defaults.
ENV_DEPLOY_HOST="${DEPLOY_HOST-}"
ENV_DEPLOY_USER="${DEPLOY_USER-}"
ENV_DEPLOY_PATH="${DEPLOY_PATH-}"
ENV_WWW_USER="${WWW_USER-}"
ENV_NPM_CACHE_DIR="${NPM_CACHE_DIR-}"
ENV_GITEA_REMOTE="${GITEA_REMOTE-}"
ENV_GITEA_URL="${GITEA_URL-}"
ENV_GITHUB_REMOTE="${GITHUB_REMOTE-}"
ENV_GITHUB_URL="${GITHUB_URL-}"
ENV_PUSH_BRANCH="${PUSH_BRANCH-}"
DEPLOY_HOST="192.168.100.50"
DEPLOY_USER="root"
DEPLOY_PATH="/var/www/jabali"
WWW_USER="www-data"
NPM_CACHE_DIR=""
GITEA_REMOTE="gitea"
GITEA_URL=""
GITHUB_REMOTE="origin"
GITHUB_URL=""
PUSH_BRANCH=""
trim_ws() {
local s="${1:-}"
s="$(echo "$s" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')"
printf '%s' "$s"
}
toml_unquote() {
local v
v="$(trim_ws "${1:-}")"
# Only accept simple double-quoted strings, booleans, or integers.
if [[ ${#v} -ge 2 && "${v:0:1}" == '"' && "${v: -1}" == '"' ]]; then
printf '%s' "${v:1:${#v}-2}"
return 0
fi
if [[ "$v" =~ ^(true|false)$ ]]; then
printf '%s' "$v"
return 0
fi
if [[ "$v" =~ ^-?[0-9]+$ ]]; then
printf '%s' "$v"
return 0
fi
return 1
}
load_config_toml() {
local file section line key raw value
file="$1"
section=""
[[ -f "$file" ]] || return 0
while IFS= read -r line || [[ -n "$line" ]]; do
# Strip comments and whitespace.
line="${line%%#*}"
line="$(trim_ws "$line")"
[[ -z "$line" ]] && continue
if [[ "$line" =~ ^\[([A-Za-z0-9_.-]+)\]$ ]]; then
section="${BASH_REMATCH[1]}"
continue
fi
[[ "$section" == "deploy" ]] || continue
if [[ "$line" =~ ^([A-Za-z0-9_]+)[[:space:]]*=[[:space:]]*(.+)$ ]]; then
key="${BASH_REMATCH[1]}"
raw="${BASH_REMATCH[2]}"
value=""
if ! value="$(toml_unquote "$raw")"; then
continue
fi
case "$key" in
host) DEPLOY_HOST="$value" ;;
user) DEPLOY_USER="$value" ;;
path) DEPLOY_PATH="$value" ;;
www_user) WWW_USER="$value" ;;
npm_cache_dir) NPM_CACHE_DIR="$value" ;;
gitea_remote) GITEA_REMOTE="$value" ;;
gitea_url) GITEA_URL="$value" ;;
github_remote) GITHUB_REMOTE="$value" ;;
github_url) GITHUB_URL="$value" ;;
push_branch) PUSH_BRANCH="$value" ;;
esac
fi
done < "$file"
}
load_config_toml "$CONFIG_FILE"
# Apply environment overrides on top of config.
if [[ -n "${ENV_DEPLOY_HOST:-}" ]]; then DEPLOY_HOST="$ENV_DEPLOY_HOST"; fi
if [[ -n "${ENV_DEPLOY_USER:-}" ]]; then DEPLOY_USER="$ENV_DEPLOY_USER"; fi
if [[ -n "${ENV_DEPLOY_PATH:-}" ]]; then DEPLOY_PATH="$ENV_DEPLOY_PATH"; fi
if [[ -n "${ENV_WWW_USER:-}" ]]; then WWW_USER="$ENV_WWW_USER"; fi
if [[ -n "${ENV_NPM_CACHE_DIR:-}" ]]; then NPM_CACHE_DIR="$ENV_NPM_CACHE_DIR"; fi
if [[ -n "${ENV_GITEA_REMOTE:-}" ]]; then GITEA_REMOTE="$ENV_GITEA_REMOTE"; fi
if [[ -n "${ENV_GITEA_URL:-}" ]]; then GITEA_URL="$ENV_GITEA_URL"; fi
if [[ -n "${ENV_GITHUB_REMOTE:-}" ]]; then GITHUB_REMOTE="$ENV_GITHUB_REMOTE"; fi
if [[ -n "${ENV_GITHUB_URL:-}" ]]; then GITHUB_URL="$ENV_GITHUB_URL"; fi
if [[ -n "${ENV_PUSH_BRANCH:-}" ]]; then PUSH_BRANCH="$ENV_PUSH_BRANCH"; fi
SKIP_SYNC=0
SKIP_COMPOSER=0
SKIP_NPM=0
SKIP_MIGRATE=0
SKIP_CACHE=0
SKIP_AGENT_RESTART=0
DELETE_REMOTE=0
DRY_RUN=0
PUSH_GITEA=0
PUSH_GITHUB=0
SKIP_PUSH=0
PUSH_GITEA=1
PUSH_GITHUB=1
SET_VERSION=""
usage() {
@@ -39,19 +145,24 @@ Options:
--skip-npm Skip npm install/build
--skip-migrate Skip php artisan migrate
--skip-cache Skip cache clear/rebuild
--skip-agent-restart Skip restarting jabali-agent service
--delete Pass --delete to rsync (dangerous)
--dry-run Dry-run rsync only
--push-gitea Push current branch to Gitea before deploy
--skip-push Skip all git push operations
--push-gitea Push current branch to Gitea from deploy server (default: on)
--no-push-gitea Disable Gitea push
--gitea-remote NAME Gitea git remote name (default: gitea)
--gitea-url URL Push to this URL instead of a named remote
--push-github Push current branch to GitHub before deploy
--push-github Push current branch to GitHub from deploy server (default: on)
--no-push-github Disable GitHub push
--github-remote NAME GitHub git remote name (default: origin)
--github-url URL Push to this URL instead of a named remote
--version VALUE Set VERSION to a specific value before push
--version VALUE Set VERSION to a specific value before remote push
-h, --help Show this help
Environment overrides:
DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
CONFIG_FILE points to a TOML file (default: ./config.toml). The script reads [deploy] keys.
CONFIG_FILE, DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH, WWW_USER, NPM_CACHE_DIR, GITEA_REMOTE, GITEA_URL, GITHUB_REMOTE, GITHUB_URL, PUSH_BRANCH
EOF
}
@@ -93,6 +204,10 @@ while [[ $# -gt 0 ]]; do
SKIP_CACHE=1
shift
;;
--skip-agent-restart)
SKIP_AGENT_RESTART=1
shift
;;
--delete)
DELETE_REMOTE=1
shift
@@ -101,10 +216,20 @@ while [[ $# -gt 0 ]]; do
DRY_RUN=1
shift
;;
--skip-push)
SKIP_PUSH=1
PUSH_GITEA=0
PUSH_GITHUB=0
shift
;;
--push-gitea)
PUSH_GITEA=1
shift
;;
--no-push-gitea)
PUSH_GITEA=0
shift
;;
--gitea-remote)
GITEA_REMOTE="$2"
shift 2
@@ -117,6 +242,10 @@ while [[ $# -gt 0 ]]; do
PUSH_GITHUB=1
shift
;;
--no-push-github)
PUSH_GITHUB=0
shift
;;
--github-remote)
GITHUB_REMOTE="$2"
shift 2
@@ -143,22 +272,71 @@ done
REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}"
ensure_clean_worktree() {
if ! git -C "$ROOT_DIR" diff --quiet || ! git -C "$ROOT_DIR" diff --cached --quiet; then
echo "Working tree is dirty. Commit or stash changes before pushing."
ensure_remote_git_clean() {
local status_output
status_output="$(remote_run "if [[ ! -d \"$DEPLOY_PATH/.git\" ]]; then echo '__NO_GIT__'; exit 0; fi; cd \"$DEPLOY_PATH\" && git status --porcelain")"
if [[ "$status_output" == "__NO_GIT__" ]]; then
echo "Remote path is not a git repository: $DEPLOY_PATH"
exit 1
fi
if [[ -n "$status_output" ]]; then
echo "Remote git worktree is dirty at $DEPLOY_PATH. Commit or stash remote changes first."
echo "$status_output"
exit 1
fi
}
get_current_version() {
sed -n 's/^VERSION=//p' "$ROOT_DIR/VERSION"
}
remote_commit_and_push() {
local local_head push_branch
local_head="$(git -C "$ROOT_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
bump_version() {
local current new base num
current="$(get_current_version)"
if [[ -n "$PUSH_BRANCH" ]]; then
push_branch="$PUSH_BRANCH"
else
push_branch="$(remote_run "cd \"$DEPLOY_PATH\" && git rev-parse --abbrev-ref HEAD")"
if [[ -z "$push_branch" || "$push_branch" == "HEAD" ]]; then
push_branch="main"
fi
fi
if [[ -n "$SET_VERSION" ]]; then
ssh -o StrictHostKeyChecking=no "$REMOTE" \
DEPLOY_PATH="$DEPLOY_PATH" \
PUSH_BRANCH="$push_branch" \
PUSH_GITEA="$PUSH_GITEA" \
PUSH_GITHUB="$PUSH_GITHUB" \
GITEA_REMOTE="$GITEA_REMOTE" \
GITEA_URL="$GITEA_URL" \
GITHUB_REMOTE="$GITHUB_REMOTE" \
GITHUB_URL="$GITHUB_URL" \
SET_VERSION="$SET_VERSION" \
LOCAL_HEAD="$local_head" \
bash -s <<'EOF'
set -euo pipefail
cd "$DEPLOY_PATH"
if [[ ! -d .git ]]; then
echo "Remote path is not a git repository: $DEPLOY_PATH" >&2
exit 1
fi
git config --global --add safe.directory "$DEPLOY_PATH" >/dev/null 2>&1 || true
if ! git config user.name >/dev/null; then
git config user.name "Jabali Deploy"
fi
if ! git config user.email >/dev/null; then
git config user.email "root@$(hostname -f 2>/dev/null || hostname)"
fi
current="$(sed -n 's/^VERSION=//p' VERSION || true)"
if [[ -z "$current" ]]; then
echo "VERSION file missing or invalid on remote." >&2
exit 1
fi
if [[ -n "${SET_VERSION:-}" ]]; then
new="$SET_VERSION"
else
if [[ "$current" =~ ^(.+-rc)([0-9]+)?$ ]]; then
@@ -173,51 +351,42 @@ bump_version() {
elif [[ "$current" =~ ^(.+?)([0-9]+)$ ]]; then
new="${BASH_REMATCH[1]}$((BASH_REMATCH[2] + 1))"
else
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly."
echo "Cannot auto-bump VERSION from '$current'. Use --version to set it explicitly." >&2
exit 1
fi
fi
if [[ "$new" == "$current" ]]; then
echo "VERSION is already '$current'. Use --version to set a new value."
echo "VERSION is already '$current'. Use --version to set a new value." >&2
exit 1
fi
printf 'VERSION=%s\n' "$new" > "$ROOT_DIR/VERSION"
perl -0pi -e "s/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-[^\\\"]+\\}\\\"/JABALI_VERSION=\\\"\\$\\{JABALI_VERSION:-$new\\}\\\"/g" "$ROOT_DIR/install.sh"
printf 'VERSION=%s\n' "$new" > VERSION
sed -i -E "s|JABALI_VERSION=\"\\$\\{JABALI_VERSION:-[^}]+\\}\"|JABALI_VERSION=\"\\\${JABALI_VERSION:-$new}\"|" install.sh
git -C "$ROOT_DIR" add VERSION install.sh
if ! git -C "$ROOT_DIR" diff --cached --quiet; then
git -C "$ROOT_DIR" commit -m "Bump VERSION to $new"
fi
}
prepare_push() {
ensure_clean_worktree
bump_version
}
push_remote() {
local label="$1"
local remote_name="$2"
local remote_url="$3"
local target
if [[ -n "$remote_url" ]]; then
target="$remote_url"
git add -A
if git diff --cached --quiet; then
echo "No changes detected after sync/version bump; skipping commit."
else
if ! git -C "$ROOT_DIR" remote get-url "$remote_name" >/dev/null 2>&1; then
echo "$label remote '$remote_name' not found. Use --${label,,}-url or --${label,,}-remote."
exit 1
fi
target="$remote_name"
git commit -m "Deploy sync from ${LOCAL_HEAD} (v${new})"
fi
if [[ -z "$PUSH_BRANCH" ]]; then
PUSH_BRANCH="$(git -C "$ROOT_DIR" rev-parse --abbrev-ref HEAD)"
if [[ "$PUSH_GITEA" -eq 1 ]]; then
if [[ -n "$GITEA_URL" ]]; then
git push "$GITEA_URL" "$PUSH_BRANCH"
else
git push "$GITEA_REMOTE" "$PUSH_BRANCH"
fi
fi
git -C "$ROOT_DIR" push "$target" "$PUSH_BRANCH"
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
if [[ -n "$GITHUB_URL" ]]; then
git push "$GITHUB_URL" "$PUSH_BRANCH"
else
git push "$GITHUB_REMOTE" "$PUSH_BRANCH"
fi
fi
EOF
}
rsync_project() {
@@ -269,18 +438,9 @@ ensure_remote_permissions() {
echo "Deploying to ${REMOTE}:${DEPLOY_PATH}"
if [[ "$PUSH_GITEA" -eq 1 ]]; then
prepare_push
echo "Pushing to Gitea..."
push_remote "Gitea" "$GITEA_REMOTE" "$GITEA_URL"
fi
if [[ "$PUSH_GITHUB" -eq 1 ]]; then
if [[ "$PUSH_GITEA" -ne 1 ]]; then
prepare_push
fi
echo "Pushing to GitHub..."
push_remote "GitHub" "$GITHUB_REMOTE" "$GITHUB_URL"
if [[ "$DRY_RUN" -eq 0 && "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
echo "Validating remote git worktree..."
ensure_remote_git_clean
fi
if [[ "$SKIP_SYNC" -eq 0 ]]; then
@@ -293,6 +453,11 @@ if [[ "$DRY_RUN" -eq 1 ]]; then
exit 0
fi
if [[ "$SKIP_PUSH" -eq 0 && ( "$PUSH_GITEA" -eq 1 || "$PUSH_GITHUB" -eq 1 ) ]]; then
echo "Committing and pushing from ${REMOTE}..."
remote_commit_and_push
fi
echo "Ensuring remote permissions..."
ensure_remote_permissions
@@ -320,4 +485,9 @@ if [[ "$SKIP_CACHE" -eq 0 ]]; then
remote_run_www "php artisan view:cache"
fi
if [[ "$SKIP_AGENT_RESTART" -eq 0 ]]; then
echo "Restarting jabali-agent service..."
remote_run "if systemctl list-unit-files jabali-agent.service --no-legend 2>/dev/null | grep -q '^jabali-agent\\.service'; then systemctl restart jabali-agent; fi"
fi
echo "Deploy complete."

View File

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