Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class AuditHostingPhpFpmPoolsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:audit-php-fpm-pools
|
||||
{--account= : Limit to one hosting account id}
|
||||
{--repair : Remove duplicate pools and keep the account PHP version}';
|
||||
|
||||
protected $description = 'Scan shared hosting accounts for duplicate PHP-FPM pool configs across PHP versions.';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->with('node')
|
||||
->where('type', HostingAccount::TYPE_SHARED)
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId))
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No hosting accounts matched.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$issues = 0;
|
||||
$repaired = 0;
|
||||
$failures = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->node) {
|
||||
$this->warn("[{$account->id}] {$account->username}: no node assigned");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$versions = $provider->findPhpFpmPoolVersions($account);
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error("[{$account->id}] {$account->username}: {$e->getMessage()}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($versions === []) {
|
||||
$this->line("[{$account->id}] {$account->username}: no pool configs found");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetVersion = $account->php_version ?? '8.4';
|
||||
$status = count($versions) === 1 && $versions[0] === $targetVersion
|
||||
? 'ok'
|
||||
: 'duplicate';
|
||||
|
||||
$this->line(sprintf(
|
||||
'[%d] %s: pools=%s target=%s (%s)',
|
||||
$account->id,
|
||||
$account->username,
|
||||
implode(', ', $versions),
|
||||
$targetVersion,
|
||||
$status,
|
||||
));
|
||||
|
||||
if ($status === 'ok') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$issues++;
|
||||
|
||||
if (! $this->option('repair')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($provider->changeAccountPhpVersion($account, $targetVersion)) {
|
||||
$repaired++;
|
||||
$this->info(" repaired {$account->username}");
|
||||
} else {
|
||||
$failures++;
|
||||
$this->error(" failed to repair {$account->username}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error(" failed to repair {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Accounts scanned: {$accounts->count()}");
|
||||
$this->info("Issues found: {$issues}");
|
||||
|
||||
if ($this->option('repair')) {
|
||||
$this->info("Repaired: {$repaired}");
|
||||
$this->info("Failures: {$failures}");
|
||||
}
|
||||
|
||||
return $failures > 0 ? self::FAILURE : ($issues > 0 && ! $this->option('repair') ? self::FAILURE : self::SUCCESS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeMonitoringService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class CheckNodeHealthCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:check-node-health
|
||||
{--node= : Check a specific node by ID}
|
||||
{--all : Check all active nodes}';
|
||||
|
||||
protected $description = 'Run health checks on hosting nodes to collect resource metrics';
|
||||
|
||||
public function handle(NodeMonitoringService $monitoringService): int
|
||||
{
|
||||
$nodeId = $this->option('node');
|
||||
|
||||
if ($nodeId) {
|
||||
return $this->checkSingleNode($monitoringService, (int) $nodeId);
|
||||
}
|
||||
|
||||
return $this->checkAllNodes($monitoringService);
|
||||
}
|
||||
|
||||
private function checkSingleNode(NodeMonitoringService $monitoringService, int $nodeId): int
|
||||
{
|
||||
$node = HostingNode::find($nodeId);
|
||||
|
||||
if (!$node) {
|
||||
$this->error("Node with ID {$nodeId} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Checking node: {$node->name} ({$node->ip_address})...");
|
||||
|
||||
$result = $monitoringService->checkNode($node);
|
||||
|
||||
$this->displayNodeResult($node, $result);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function checkAllNodes(NodeMonitoringService $monitoringService): int
|
||||
{
|
||||
$nodes = HostingNode::whereIn('status', [
|
||||
HostingNode::STATUS_ACTIVE,
|
||||
HostingNode::STATUS_FULL,
|
||||
HostingNode::STATUS_MAINTENANCE,
|
||||
])->get();
|
||||
|
||||
if ($nodes->isEmpty()) {
|
||||
$this->warn('No active nodes found to check.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Checking {$nodes->count()} node(s)...");
|
||||
$this->newLine();
|
||||
|
||||
$results = [];
|
||||
$bar = $this->output->createProgressBar($nodes->count());
|
||||
$bar->start();
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$results[$node->id] = $monitoringService->checkNode($node);
|
||||
$bar->advance();
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
$this->newLine(2);
|
||||
|
||||
$this->displaySummaryTable($nodes, $results);
|
||||
|
||||
$unhealthy = collect($results)->filter(fn($r) => ($r['health'] ?? '') !== NodeMonitoringService::HEALTH_OK);
|
||||
if ($unhealthy->isNotEmpty()) {
|
||||
$this->newLine();
|
||||
$this->warn("⚠️ {$unhealthy->count()} node(s) require attention.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function displayNodeResult(HostingNode $node, array $result): void
|
||||
{
|
||||
$health = $result['health'] ?? 'unknown';
|
||||
$healthIcon = match ($health) {
|
||||
NodeMonitoringService::HEALTH_OK => '✅',
|
||||
NodeMonitoringService::HEALTH_WARNING => '⚠️',
|
||||
NodeMonitoringService::HEALTH_CRITICAL => '🔴',
|
||||
NodeMonitoringService::HEALTH_UNREACHABLE => '❌',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$this->newLine();
|
||||
$this->info("{$healthIcon} Health: " . strtoupper($health));
|
||||
$this->newLine();
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$this->error("Error: {$result['error']}");
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Metric', 'Value'],
|
||||
[
|
||||
['CPU Usage', ($result['cpu']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['RAM Usage', ($result['memory']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['Disk Usage (root)', ($result['disk']['root']['usage_percent'] ?? 'N/A') . '%'],
|
||||
['Load (1m)', $result['load']['load_1'] ?? 'N/A'],
|
||||
['Load per Core', $result['load']['load_per_core'] ?? 'N/A'],
|
||||
['Uptime', $result['uptime']['formatted'] ?? 'N/A'],
|
||||
['Processes', $result['processes']['total'] ?? 'N/A'],
|
||||
['Response Time', ($result['response_time_ms'] ?? 'N/A') . 'ms'],
|
||||
]
|
||||
);
|
||||
|
||||
if (!empty($result['services'])) {
|
||||
$this->newLine();
|
||||
$this->info('Services:');
|
||||
foreach ($result['services'] as $service => $running) {
|
||||
$icon = $running ? '✅' : '❌';
|
||||
$this->line(" {$icon} {$service}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function displaySummaryTable($nodes, array $results): void
|
||||
{
|
||||
$rows = [];
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$result = $results[$node->id] ?? [];
|
||||
$health = $result['health'] ?? 'unknown';
|
||||
|
||||
$healthIcon = match ($health) {
|
||||
NodeMonitoringService::HEALTH_OK => '✅',
|
||||
NodeMonitoringService::HEALTH_WARNING => '⚠️',
|
||||
NodeMonitoringService::HEALTH_CRITICAL => '🔴',
|
||||
NodeMonitoringService::HEALTH_UNREACHABLE => '❌',
|
||||
default => '❓',
|
||||
};
|
||||
|
||||
$rows[] = [
|
||||
$node->name,
|
||||
$node->ip_address,
|
||||
"{$healthIcon} " . strtoupper($health),
|
||||
isset($result['cpu']['usage_percent']) ? $result['cpu']['usage_percent'] . '%' : 'N/A',
|
||||
isset($result['memory']['usage_percent']) ? $result['memory']['usage_percent'] . '%' : 'N/A',
|
||||
isset($result['disk']['root']['usage_percent']) ? $result['disk']['root']['usage_percent'] . '%' : 'N/A',
|
||||
$result['load']['load_1'] ?? 'N/A',
|
||||
"{$node->current_accounts}/{$node->max_accounts}",
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['Node', 'IP', 'Health', 'CPU', 'RAM', 'Disk', 'Load', 'Accounts'],
|
||||
$rows
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class HardenHostingAccountIsolationCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:harden-account-isolation {--account= : Limit to one hosting account id} {--dry-run : Show affected accounts without applying changes}';
|
||||
|
||||
protected $description = 'Apply filesystem isolation permissions to shared hosting accounts.';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->with(['node', 'sites'])
|
||||
->where('type', HostingAccount::TYPE_SHARED)
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->whereKey($accountId))
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No hosting accounts matched.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$this->line(sprintf('[%d] %s (%s)', $account->id, $account->username, $account->primary_domain ?: 'no-domain'));
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run mode: no changes applied.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$failures = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$provider->hardenAccountFilesystem($account);
|
||||
$this->info("Hardened {$account->username}");
|
||||
} catch (\Throwable $e) {
|
||||
$failures++;
|
||||
$this->error("Failed {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return $failures === 0 ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingBillingInvoice;
|
||||
use App\Models\HostingNode;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Models\HostingPlanChange;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Zero-loss import of hosting data exported from the platform (`hosting:export`).
|
||||
* Dry-run by default; pass --commit to write.
|
||||
*/
|
||||
class ImportHostingCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:import
|
||||
{file : Path to hosting-export.json from the platform}
|
||||
{--commit : Actually write changes}';
|
||||
|
||||
protected $description = 'Import hosting accounts and orders from the platform export';
|
||||
|
||||
/** @var array<int,int> platform user_id → local user id */
|
||||
private array $userMap = [];
|
||||
|
||||
/** @var array<int,int> platform account id → local account id */
|
||||
private array $accountMap = [];
|
||||
|
||||
/** @var array<int,int> platform node id → local node id */
|
||||
private array $nodeMap = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$commit = (bool) $this->option('commit');
|
||||
|
||||
if (! $commit) {
|
||||
$this->warn('DRY RUN — no changes will be written. Re-run with --commit to apply.');
|
||||
}
|
||||
|
||||
$path = $this->argument('file');
|
||||
if (! is_file($path)) {
|
||||
$this->error("File not found: {$path}");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$payload = json_decode((string) file_get_contents($path), true);
|
||||
if (! is_array($payload)) {
|
||||
$this->error('Invalid JSON export file.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$apply = function (callable $callback) use ($commit): void {
|
||||
if ($commit) {
|
||||
DB::transaction($callback);
|
||||
} else {
|
||||
$callback();
|
||||
}
|
||||
};
|
||||
|
||||
$apply(function () use ($payload, $commit): void {
|
||||
foreach ($payload['hosting_nodes'] ?? [] as $row) {
|
||||
$this->importNode($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_accounts'] ?? [] as $row) {
|
||||
$this->importAccount($row, $commit);
|
||||
}
|
||||
foreach ($payload['customer_hosting_orders'] ?? [] as $row) {
|
||||
$this->importCustomerOrder($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_orders'] ?? [] as $row) {
|
||||
$this->importLegacyOrder($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_account_members'] ?? [] as $row) {
|
||||
$this->importMember($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_billing_invoices'] ?? [] as $row) {
|
||||
$this->importInvoice($row, $commit);
|
||||
}
|
||||
foreach ($payload['hosting_plan_changes'] ?? [] as $row) {
|
||||
$this->importPlanChange($row, $commit);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(($commit ? 'Imported' : 'Would import').' '.count($this->accountMap).' hosting account(s).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importNode(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
unset($row['id'], $row['primary_hosting_node_id']);
|
||||
|
||||
if ($commit) {
|
||||
$node = HostingNode::updateOrCreate(['hostname' => $row['hostname']], $row);
|
||||
$this->nodeMap[$platformId] = $node->id;
|
||||
} else {
|
||||
$this->nodeMap[$platformId] = $platformId;
|
||||
$this->line(" node {$platformId} → {$row['hostname']}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importAccount(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser(
|
||||
(string) ($row['owner_public_id'] ?? ''),
|
||||
(string) ($row['owner_email'] ?? ''),
|
||||
);
|
||||
if (! $owner) {
|
||||
$this->warn("Skipping account #{$platformId}: no owner public_id");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
$this->mapProductId($row);
|
||||
|
||||
$platformNodeId = (int) ($row['hosting_node_id'] ?? 0);
|
||||
if ($platformNodeId && isset($this->nodeMap[$platformNodeId])) {
|
||||
$row['hosting_node_id'] = $this->nodeMap[$platformNodeId];
|
||||
}
|
||||
|
||||
if ($commit) {
|
||||
$account = HostingAccount::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
$this->accountMap[$platformId] = $account->id;
|
||||
} else {
|
||||
$this->accountMap[$platformId] = $platformId;
|
||||
$this->line(" account {$platformId} → user {$owner->email}");
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importCustomerOrder(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
$this->mapProductId($row);
|
||||
|
||||
if ($commit) {
|
||||
CustomerHostingOrder::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importLegacyOrder(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
if (! $owner) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingOrder::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importMember(array $row, bool $commit): void
|
||||
{
|
||||
$member = $this->resolveUser((string) ($row['member_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $member || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['member_public_id']);
|
||||
$row['user_id'] = $member->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
|
||||
if (! empty($row['invited_by_user_id']) && ! User::query()->whereKey($row['invited_by_user_id'])->exists()) {
|
||||
$row['invited_by_user_id'] = null;
|
||||
}
|
||||
|
||||
if ($commit) {
|
||||
HostingAccountMember::updateOrCreate(
|
||||
['hosting_account_id' => $localAccountId, 'user_id' => $member->id],
|
||||
$row
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importInvoice(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $owner || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingBillingInvoice::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function importPlanChange(array $row, bool $commit): void
|
||||
{
|
||||
$platformId = (int) ($row['id'] ?? 0);
|
||||
$owner = $this->resolveUser((string) ($row['owner_public_id'] ?? ''));
|
||||
$platformAccountId = (int) ($row['hosting_account_id'] ?? 0);
|
||||
$localAccountId = $this->accountMap[$platformAccountId] ?? null;
|
||||
|
||||
if (! $owner || ! $localAccountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($row['id'], $row['owner_public_id'], $row['owner_email']);
|
||||
$row['user_id'] = $owner->id;
|
||||
$row['hosting_account_id'] = $localAccountId;
|
||||
$row['platform_id'] = $platformId;
|
||||
|
||||
if ($commit) {
|
||||
HostingPlanChange::updateOrCreate(['platform_id' => $platformId], $row);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveUser(string $publicId, string $email = '', string $name = ''): ?User
|
||||
{
|
||||
if ($publicId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->where('public_id', $publicId)->first();
|
||||
if ($user || $email === '') {
|
||||
return $user;
|
||||
}
|
||||
|
||||
return User::query()->create([
|
||||
'public_id' => $publicId,
|
||||
'email' => $email,
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $row */
|
||||
private function mapProductId(array &$row): void
|
||||
{
|
||||
$slug = (string) ($row['hosting_product_slug'] ?? '');
|
||||
unset($row['hosting_product_slug']);
|
||||
|
||||
if ($slug === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$product = HostingProduct::query()->where('slug', $slug)->first();
|
||||
if ($product) {
|
||||
$row['hosting_product_id'] = $product->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Hosting\HostingExpiryAlertService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class NotifyExpiringHostingCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:notify-expiring';
|
||||
|
||||
protected $description = 'Send hosting plan expiry reminder emails';
|
||||
|
||||
public function handle(HostingExpiryAlertService $alerts): int
|
||||
{
|
||||
$sent = $alerts->checkAll();
|
||||
|
||||
$this->info($sent > 0
|
||||
? "Sent {$sent} hosting expiry reminder(s)."
|
||||
: 'No hosting expiry reminders to send.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Notifications\HostingSuspendedNotification;
|
||||
use App\Services\Hosting\HostingProvisioningService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProcessExpiredHostingAccountsCommand extends Command
|
||||
{
|
||||
private const EXPIRY_SUSPENSION_GRACE_DAYS = 7;
|
||||
|
||||
protected $signature = 'hosting:process-expired-accounts';
|
||||
|
||||
protected $description = 'Suspend expired hosting accounts (serve expired page) and terminate those past the grace period';
|
||||
|
||||
public function handle(SharedNodeProvider $nodeProvider, HostingProvisioningService $provisioning): int
|
||||
{
|
||||
$this->suspendNewlyExpired($nodeProvider);
|
||||
$this->terminatePastGracePeriod($provisioning);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function suspendNewlyExpired(SharedNodeProvider $nodeProvider): void
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', now()->subDays(self::EXPIRY_SUSPENSION_GRACE_DAYS))
|
||||
->with(['node', 'sites', 'user'])
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No newly expired accounts to suspend.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found {$accounts->count()} expired account(s) to suspend.");
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$nodeProvider->serveExpiredPage($account);
|
||||
|
||||
$reason = 'Hosting account expired on '.$account->expires_at->format('M d, Y').'. Renew to restore service.';
|
||||
|
||||
$account->update([
|
||||
'status' => HostingAccount::STATUS_SUSPENDED,
|
||||
'suspension_reason' => $reason,
|
||||
'suspended_at' => now(),
|
||||
'metadata' => array_merge((array) ($account->metadata ?? []), [
|
||||
'suspension_origin' => 'expiry',
|
||||
'expired_at' => $account->expires_at->toIso8601String(),
|
||||
'expiry_suspension_grace_days' => self::EXPIRY_SUSPENSION_GRACE_DAYS,
|
||||
'grace_period_ends_at' => $account->gracePeriodEndsAt()->toIso8601String(),
|
||||
]),
|
||||
]);
|
||||
|
||||
if ($account->user) {
|
||||
$account->user->notify(new HostingSuspendedNotification($account->fresh(), $reason));
|
||||
}
|
||||
|
||||
Log::info('ProcessExpiredHostingAccounts: Suspended expired account', [
|
||||
'account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'expired_at' => $account->expires_at,
|
||||
]);
|
||||
|
||||
$this->line(" Suspended: {$account->username} (expired {$account->expires_at->format('M d, Y')})");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProcessExpiredHostingAccounts: Failed to suspend expired account', [
|
||||
'account_id' => $account->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->error(" Failed to suspend {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function terminatePastGracePeriod(HostingProvisioningService $provisioning): void
|
||||
{
|
||||
$graceDeadline = now()->subMonths(HostingAccount::GRACE_PERIOD_MONTHS);
|
||||
|
||||
$accounts = HostingAccount::query()
|
||||
->where('status', HostingAccount::STATUS_SUSPENDED)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<', $graceDeadline)
|
||||
->whereJsonContains('metadata->suspension_origin', 'expiry')
|
||||
->with(['node', 'sites'])
|
||||
->get();
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No accounts past grace period to terminate.');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info("Found {$accounts->count()} account(s) past grace period to terminate.");
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
try {
|
||||
$provisioning->terminateAccount($account);
|
||||
|
||||
Log::info('ProcessExpiredHostingAccounts: Terminated account past grace period', [
|
||||
'account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'expired_at' => $account->expires_at,
|
||||
]);
|
||||
|
||||
$this->line(" Terminated: {$account->username} (expired {$account->expires_at->format('M d, Y')})");
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ProcessExpiredHostingAccounts: Failed to terminate account', [
|
||||
'account_id' => $account->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->error(" Failed to terminate {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ProvisionHostingAccountJob;
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ProvisionPendingHostingAccountsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:provision-pending {--dry-run : Show accounts without dispatching jobs}';
|
||||
|
||||
protected $description = 'Dispatch provisioning jobs for hosting accounts stuck in pending status';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$pendingAccounts = HostingAccount::query()
|
||||
->whereIn('status', ['pending', 'failed'])
|
||||
->whereNotNull('hosting_node_id')
|
||||
->whereNull('provisioned_at')
|
||||
->with(['user:id,name,email', 'product:id,name', 'node:id,name'])
|
||||
->get();
|
||||
|
||||
if ($pendingAccounts->isEmpty()) {
|
||||
$this->info('No pending hosting accounts found.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$pendingAccounts->count()} pending account(s):");
|
||||
$this->newLine();
|
||||
|
||||
foreach ($pendingAccounts as $account) {
|
||||
$this->line(" • [{$account->id}] {$account->username} - {$account->user?->name} ({$account->user?->email})");
|
||||
$this->line(" Product: {$account->product?->name}, Node: {$account->node?->name}, Status: {$account->status}");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run mode - no jobs dispatched.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->confirm('Dispatch provisioning jobs for these accounts?')) {
|
||||
$this->info('Aborted.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$dispatched = 0;
|
||||
foreach ($pendingAccounts as $account) {
|
||||
// Reset status to pending if it was failed
|
||||
if ($account->status === 'failed') {
|
||||
$account->update(['status' => 'pending', 'notes' => null]);
|
||||
}
|
||||
|
||||
ProvisionHostingAccountJob::dispatch($account->id);
|
||||
$dispatched++;
|
||||
$this->info("Dispatched job for account #{$account->id} ({$account->username})");
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Dispatched {$dispatched} provisioning job(s). Check queue worker for progress.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RecalculateHostingNodeCapacityCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:recalculate-node-capacity {--node=} {--shared-only}';
|
||||
|
||||
protected $description = 'Recalculate logical capacity, allocated disk, and load for hosting nodes.';
|
||||
|
||||
public function handle(NodeCapacityService $capacityService): int
|
||||
{
|
||||
$nodes = HostingNode::query()
|
||||
->when($this->option('node'), fn ($query, $nodeId) => $query->where('id', $nodeId))
|
||||
->when($this->option('shared-only'), fn ($query) => $query->where('type', HostingNode::TYPE_SHARED))
|
||||
->get();
|
||||
|
||||
foreach ($nodes as $node) {
|
||||
$node = $capacityService->refreshNodeResourceTotals($node);
|
||||
$capacityService->checkNode($node);
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: accounts=%d allocated=%dGB used=%dGB logical=%.2fGB load=%.2f%%',
|
||||
$node->name,
|
||||
$node->current_accounts,
|
||||
$node->allocated_disk_gb,
|
||||
$node->used_disk_gb,
|
||||
$node->logicalCapacityGb(),
|
||||
(float) $node->current_load_percent
|
||||
));
|
||||
}
|
||||
|
||||
$this->info("Recalculated capacity for {$nodes->count()} node(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Ssl\SslRenewalService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RenewSslCertificatesCommand extends Command
|
||||
{
|
||||
protected $signature = 'ssl:renew
|
||||
{--force : Force renewal even if certificates are not due}
|
||||
{--dry-run : Report how many targets would be processed without making changes}';
|
||||
|
||||
protected $description = 'Renew Let\'s Encrypt SSL certificates for all Ladill-hosted domains';
|
||||
|
||||
public function handle(SslRenewalService $renewalService): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($dryRun) {
|
||||
$stats = $renewalService->renewAll($force, true);
|
||||
$this->info('SSL renewal dry run:');
|
||||
$this->line(" Shared hosting nodes with SSL: {$stats['nodes']}");
|
||||
$this->line(' Central web server renewal: ' . ($stats['central'] ? 'yes' : 'no'));
|
||||
$this->line(" Managed VPS orders to queue: {$stats['server_orders']}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Renewing SSL certificates...');
|
||||
$stats = $renewalService->renewAll($force, false);
|
||||
|
||||
$this->info("Renewed on {$stats['nodes']} shared hosting node(s).");
|
||||
if ($stats['central']) {
|
||||
$this->info('Central web server certificates renewed.');
|
||||
}
|
||||
if ($stats['server_orders'] > 0) {
|
||||
$this->info("Queued ssl.renew for {$stats['server_orders']} managed server(s).");
|
||||
}
|
||||
$this->line("Synced expiry for {$stats['sites_synced']} hosted site(s) and {$stats['domains_synced']} domain record(s).");
|
||||
if ($stats['fallbacks'] > 0) {
|
||||
$this->warn("Re-issued {$stats['fallbacks']} certificate(s) via fallback provisioning.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostedSite;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RepairSslCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:repair-ssl
|
||||
{domain? : Specific domain to repair (e.g., dailya.cc)}
|
||||
{--all : Repair SSL for all sites with ssl_enabled=true}';
|
||||
|
||||
protected $description = 'Repair SSL configuration for hosted sites by regenerating nginx config with SSL';
|
||||
|
||||
public function handle(SharedNodeProvider $nodeProvider): int
|
||||
{
|
||||
$domain = $this->argument('domain');
|
||||
$all = $this->option('all');
|
||||
|
||||
if (!$domain && !$all) {
|
||||
$this->error('Please specify a domain or use --all flag');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$query = HostedSite::query()->where('ssl_enabled', true);
|
||||
|
||||
if ($domain) {
|
||||
$query->where('domain', $domain);
|
||||
}
|
||||
|
||||
$sites = $query->get();
|
||||
|
||||
if ($sites->isEmpty()) {
|
||||
$this->warn('No sites found matching criteria');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info("Found {$sites->count()} site(s) to repair");
|
||||
|
||||
foreach ($sites as $site) {
|
||||
$this->line("Processing {$site->domain}...");
|
||||
|
||||
try {
|
||||
// Re-request SSL certificate (certbot will reuse existing cert if valid)
|
||||
$nodeProvider->requestLetsEncryptCertificate($site);
|
||||
$this->info(" ✓ SSL repaired for {$site->domain}");
|
||||
} catch (\Exception $e) {
|
||||
$this->error(" ✗ Failed for {$site->domain}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('Done!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class ReprovisionHostingAccount extends Command
|
||||
{
|
||||
protected $signature = 'hosting:reprovision {account_id}';
|
||||
protected $description = 'Re-provision a hosting account on its node (creates user, directories, etc.)';
|
||||
|
||||
public function handle(SharedNodeProvider $provider): int
|
||||
{
|
||||
$account = HostingAccount::with('node')->find($this->argument('account_id'));
|
||||
|
||||
if (!$account) {
|
||||
$this->error('Account not found');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!$account->node) {
|
||||
$this->error('Account has no hosting node assigned');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->info("Re-provisioning account: {$account->username} on node {$account->node->hostname}");
|
||||
|
||||
try {
|
||||
$result = $provider->createAccount($account);
|
||||
|
||||
$account->update([
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'home_directory' => $result['home_directory'],
|
||||
'provisioned_at' => now(),
|
||||
]);
|
||||
|
||||
$this->info("Account provisioned successfully!");
|
||||
$this->info("Username: {$result['username']}");
|
||||
$this->info("Password: {$result['password']}");
|
||||
$this->info("Home Directory: {$result['home_directory']}");
|
||||
|
||||
// Also provision any existing sites
|
||||
foreach ($account->sites as $site) {
|
||||
$this->info("Provisioning site: {$site->domain}");
|
||||
try {
|
||||
$provider->addSite($site);
|
||||
$site->update(['status' => 'active']);
|
||||
$this->info(" - Site provisioned");
|
||||
} catch (\Exception $e) {
|
||||
$this->warn(" - Failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (\Exception $e) {
|
||||
$this->error("Failed to provision: {$e->getMessage()}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Jobs\ProvisionHostingOrderJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RetryPendingHostingFulfillmentCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:retry-pending-fulfillment {--limit=50 : Maximum orders to process}';
|
||||
|
||||
protected $description = 'Re-queue provisioning for hosting orders held after an upstream failure (e.g. Contabo balance)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->where('status', CustomerHostingOrder::STATUS_PENDING_APPROVAL)
|
||||
->whereNotNull('meta->provisioning_hold')
|
||||
->with('product')
|
||||
->latest('id')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
$queued = 0;
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$order->update([
|
||||
'status' => CustomerHostingOrder::STATUS_APPROVED,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
ProvisionHostingOrderJob::dispatch($order->fresh());
|
||||
$queued++;
|
||||
$this->line("Queued provisioning for order #{$order->id} ({$order->domain_name})");
|
||||
}
|
||||
|
||||
$this->info("Queued {$queued} order(s) for reprovisioning.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\BrowserTerminalSessionManager;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use App\Services\Hosting\Terminal\LocalPtyShellSession;
|
||||
use App\Services\Hosting\Terminal\RemoteSshShellSession;
|
||||
use App\Services\Hosting\Terminal\ShellSession;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class RunHostingTerminalWorkerCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:terminal-worker {session}';
|
||||
|
||||
protected $description = 'Run a live browser terminal worker for a hosting account session.';
|
||||
|
||||
public function handle(
|
||||
BrowserTerminalSessionManager $sessionManager,
|
||||
SharedNodeProvider $provider,
|
||||
): int {
|
||||
$sessionId = (string) $this->argument('session');
|
||||
$shell = null;
|
||||
$status = 'closed';
|
||||
$error = null;
|
||||
$eventOffset = 0;
|
||||
|
||||
try {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
$account = HostingAccount::query()
|
||||
->with('node')
|
||||
->find($metadata['account_id'] ?? null);
|
||||
|
||||
if (! $account || ! $account->node) {
|
||||
throw new \RuntimeException('Hosting account or node was not found for this terminal session.');
|
||||
}
|
||||
|
||||
$shell = $this->makeShell($account, $metadata, $provider);
|
||||
$shell->boot();
|
||||
|
||||
$metadata['status'] = 'running';
|
||||
$metadata['error'] = null;
|
||||
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
||||
$status = 'closed';
|
||||
|
||||
while (true) {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
|
||||
if ($sessionManager->isSessionIdle($metadata)) {
|
||||
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal disconnected after inactivity]\r\n");
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($sessionManager->readWorkerEvents($sessionId, $eventOffset) as $event) {
|
||||
$type = (string) ($event['type'] ?? '');
|
||||
|
||||
if ($type === 'input') {
|
||||
$shell->write((string) ($event['data'] ?? ''));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'resize') {
|
||||
$shell->resize((int) ($event['cols'] ?? 120), (int) ($event['rows'] ?? 30));
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($type === 'close') {
|
||||
$sessionManager->appendWorkerOutput($sessionId, "\r\n[Browser terminal closed]\r\n");
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
|
||||
$output = $shell->read();
|
||||
if ($output !== '') {
|
||||
$sessionManager->appendWorkerOutput($sessionId, $output);
|
||||
}
|
||||
|
||||
if (! $shell->isAlive()) {
|
||||
break;
|
||||
}
|
||||
|
||||
usleep(50000);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$status = 'failed';
|
||||
$error = $e->getMessage();
|
||||
Log::error('Hosting terminal worker failed.', [
|
||||
'session_id' => $sessionId,
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
$sessionManager->appendWorkerOutput(
|
||||
$sessionId,
|
||||
sprintf(
|
||||
"\r\n[Terminal error] %s (%s:%d)\r\n",
|
||||
$error,
|
||||
basename($e->getFile()),
|
||||
$e->getLine()
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if ($shell instanceof ShellSession) {
|
||||
$shell->close();
|
||||
}
|
||||
|
||||
try {
|
||||
$metadata = $sessionManager->readWorkerMetadata($sessionId);
|
||||
$metadata['status'] = $status;
|
||||
$metadata['error'] = $error;
|
||||
$metadata['exit_code'] = $status === 'failed' ? 1 : 0;
|
||||
$metadata['closed_at'] = now()->toIso8601String();
|
||||
$sessionManager->writeWorkerMetadata($sessionId, $metadata);
|
||||
} catch (\Throwable) {
|
||||
// Ignore metadata write failures during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
return $status === 'failed' ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
private function makeShell(HostingAccount $account, array $metadata, SharedNodeProvider $provider): ShellSession
|
||||
{
|
||||
$relativePath = $this->normalizeRelativePath((string) ($metadata['relative_path'] ?? '/public_html'));
|
||||
$cols = max(40, min((int) ($metadata['cols'] ?? 120), 240));
|
||||
$rows = max(10, min((int) ($metadata['rows'] ?? 30), 80));
|
||||
$launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows);
|
||||
|
||||
if ($this->shouldUseLocalExecution($account->node)) {
|
||||
return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand));
|
||||
}
|
||||
|
||||
$ssh = $provider->connect($account->node);
|
||||
|
||||
if (! $ssh) {
|
||||
throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.');
|
||||
}
|
||||
|
||||
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
|
||||
|
||||
return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows);
|
||||
}
|
||||
|
||||
private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string
|
||||
{
|
||||
$homeDirectory = "/home/{$account->username}";
|
||||
$workingDirectory = $relativePath === '/'
|
||||
? $homeDirectory
|
||||
: $homeDirectory.$relativePath;
|
||||
|
||||
return implode('; ', [
|
||||
'export HOME='.escapeshellarg($homeDirectory),
|
||||
'export USER='.escapeshellarg($account->username),
|
||||
'export LOGNAME='.escapeshellarg($account->username),
|
||||
'export SHELL=/bin/bash',
|
||||
'export TERM=xterm-256color',
|
||||
'export COLORTERM=truecolor',
|
||||
'export COLUMNS='.(int) $cols,
|
||||
'export LINES='.(int) $rows,
|
||||
'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '),
|
||||
'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory),
|
||||
'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true',
|
||||
'exec /bin/bash --noprofile --norc -i',
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildLocalLaunchCommand(HostingAccount $account, string $launchCommand): string
|
||||
{
|
||||
// For local development, fall back to running as current user if user switching isn't available
|
||||
$devFallback = app()->environment('local')
|
||||
? 'exec bash -lc "$LAUNCH_CMD";'
|
||||
: 'echo '.escapeshellarg('Local hosting commands require passwordless sudo, runuser access, or running the app as the hosting account user.').'; exit 1;';
|
||||
|
||||
return implode(' ', [
|
||||
'TARGET_USER='.escapeshellarg($account->username).';',
|
||||
'LAUNCH_CMD='.escapeshellarg($launchCommand).';',
|
||||
'if [ "$(id -un)" = "$TARGET_USER" ]; then exec bash -lc "$LAUNCH_CMD"; fi;',
|
||||
'if command -v sudo >/dev/null 2>&1 && sudo -n -u "$TARGET_USER" -- true >/dev/null 2>&1; then exec sudo -n -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
||||
'if command -v runuser >/dev/null 2>&1 && [ "$(id -u)" = "0" ]; then exec runuser -u "$TARGET_USER" -- bash -lc "$LAUNCH_CMD"; fi;',
|
||||
$devFallback,
|
||||
]);
|
||||
}
|
||||
|
||||
private function normalizeRelativePath(string $path): string
|
||||
{
|
||||
$path = '/'.ltrim(trim($path), '/');
|
||||
$path = preg_replace('#/+#', '/', $path) ?: '/public_html';
|
||||
|
||||
$segments = [];
|
||||
foreach (explode('/', $path) as $segment) {
|
||||
if ($segment === '' || $segment === '.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($segment === '..') {
|
||||
array_pop($segments);
|
||||
continue;
|
||||
}
|
||||
|
||||
$segments[] = $segment;
|
||||
}
|
||||
|
||||
$normalized = '/'.implode('/', $segments);
|
||||
|
||||
return $normalized === '/' ? '/' : $normalized;
|
||||
}
|
||||
|
||||
private function shouldUseLocalExecution(HostingNode $node): bool
|
||||
{
|
||||
return ($node->provider === 'local' || $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost')
|
||||
&& blank($node->ssh_private_key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingNode;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SetupNodeSshKey extends Command
|
||||
{
|
||||
protected $signature = 'hosting:setup-node-ssh {node_id} {--key-path= : Path to existing private key} {--generate : Generate a new key pair}';
|
||||
|
||||
protected $description = 'Configure SSH key authentication for a hosting node (required for all node operations)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$node = HostingNode::find($this->argument('node_id'));
|
||||
|
||||
if (!$node) {
|
||||
$this->error("Hosting node #{$this->argument('node_id')} not found.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Node: {$node->name} ({$node->ip_address})");
|
||||
|
||||
if ($this->option('generate')) {
|
||||
return $this->generateAndInstallKey($node);
|
||||
}
|
||||
|
||||
if ($this->option('key-path')) {
|
||||
return $this->installExistingKey($node, $this->option('key-path'));
|
||||
}
|
||||
|
||||
// Try common key locations
|
||||
$commonPaths = [
|
||||
'/root/.ssh/id_ed25519',
|
||||
'/root/.ssh/id_rsa',
|
||||
getenv('HOME') . '/.ssh/id_ed25519',
|
||||
getenv('HOME') . '/.ssh/id_rsa',
|
||||
];
|
||||
|
||||
foreach ($commonPaths as $path) {
|
||||
if (file_exists($path) && is_readable($path)) {
|
||||
$this->info("Found existing key at: {$path}");
|
||||
if ($this->confirm("Use this key?", true)) {
|
||||
return $this->installExistingKey($node, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->warn("No SSH key found. Use --generate to create one, or --key-path to specify an existing key.");
|
||||
$this->line("");
|
||||
$this->line("Examples:");
|
||||
$this->line(" php artisan hosting:setup-node-ssh {$node->id} --generate");
|
||||
$this->line(" php artisan hosting:setup-node-ssh {$node->id} --key-path=/root/.ssh/id_rsa");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function generateAndInstallKey(HostingNode $node): int
|
||||
{
|
||||
$keyDir = storage_path('app/ssh-keys');
|
||||
if (!is_dir($keyDir)) {
|
||||
mkdir($keyDir, 0700, true);
|
||||
}
|
||||
|
||||
$keyPath = "{$keyDir}/node_{$node->id}_ed25519";
|
||||
|
||||
if (file_exists($keyPath)) {
|
||||
if (!$this->confirm("Key already exists at {$keyPath}. Overwrite?", false)) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
unlink($keyPath);
|
||||
if (file_exists("{$keyPath}.pub")) {
|
||||
unlink("{$keyPath}.pub");
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Ed25519 key pair
|
||||
$this->info("Generating Ed25519 key pair...");
|
||||
$result = shell_exec("ssh-keygen -t ed25519 -f " . escapeshellarg($keyPath) . " -N '' -C 'ladill-node-{$node->id}' 2>&1");
|
||||
|
||||
if (!file_exists($keyPath) || !file_exists("{$keyPath}.pub")) {
|
||||
$this->error("Failed to generate key pair: {$result}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
chmod($keyPath, 0600);
|
||||
|
||||
$privateKey = file_get_contents($keyPath);
|
||||
$publicKey = trim(file_get_contents("{$keyPath}.pub"));
|
||||
|
||||
// Store private key in the database
|
||||
$node->update(['ssh_private_key' => $privateKey]);
|
||||
$this->info("Private key stored in database for node #{$node->id}.");
|
||||
|
||||
// Show instructions for installing the public key
|
||||
$this->line("");
|
||||
$this->warn("=== IMPORTANT: Install the public key on the node ===");
|
||||
$this->line("");
|
||||
|
||||
$isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local';
|
||||
|
||||
if ($isLocal) {
|
||||
$this->info("This is a local node. Installing public key automatically...");
|
||||
$authorizedKeysFile = '/root/.ssh/authorized_keys';
|
||||
|
||||
if (!is_dir('/root/.ssh')) {
|
||||
mkdir('/root/.ssh', 0700, true);
|
||||
}
|
||||
|
||||
// Check if key already installed
|
||||
$existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : '';
|
||||
if (str_contains($existingKeys, $publicKey)) {
|
||||
$this->info("Public key already installed in {$authorizedKeysFile}");
|
||||
} else {
|
||||
file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND);
|
||||
chmod($authorizedKeysFile, 0600);
|
||||
$this->info("Public key installed in {$authorizedKeysFile}");
|
||||
}
|
||||
} else {
|
||||
$this->line("Run this on the node ({$node->ip_address}):");
|
||||
$this->line("");
|
||||
$this->line(" mkdir -p /root/.ssh && chmod 700 /root/.ssh");
|
||||
$this->line(" echo '{$publicKey}' >> /root/.ssh/authorized_keys");
|
||||
$this->line(" chmod 600 /root/.ssh/authorized_keys");
|
||||
$this->line("");
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
$this->info("Testing SSH connection...");
|
||||
return $this->testConnection($node);
|
||||
}
|
||||
|
||||
private function installExistingKey(HostingNode $node, string $keyPath): int
|
||||
{
|
||||
if (!file_exists($keyPath)) {
|
||||
$this->error("Key file not found: {$keyPath}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privateKey = file_get_contents($keyPath);
|
||||
|
||||
if (!str_contains($privateKey, '-----BEGIN') && !str_contains($privateKey, 'PRIVATE KEY')) {
|
||||
$this->error("File does not appear to be a valid SSH private key.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$node->update(['ssh_private_key' => $privateKey]);
|
||||
$this->info("Private key stored in database for node #{$node->id}.");
|
||||
|
||||
// Check if public key needs to be installed
|
||||
$pubKeyPath = "{$keyPath}.pub";
|
||||
if (file_exists($pubKeyPath)) {
|
||||
$publicKey = trim(file_get_contents($pubKeyPath));
|
||||
$isLocal = $node->ip_address === '127.0.0.1' || $node->ip_address === 'localhost' || $node->provider === 'local';
|
||||
|
||||
if ($isLocal) {
|
||||
$authorizedKeysFile = '/root/.ssh/authorized_keys';
|
||||
$existingKeys = file_exists($authorizedKeysFile) ? file_get_contents($authorizedKeysFile) : '';
|
||||
if (!str_contains($existingKeys, $publicKey)) {
|
||||
$this->info("Installing public key on local node...");
|
||||
if (!is_dir('/root/.ssh')) {
|
||||
mkdir('/root/.ssh', 0700, true);
|
||||
}
|
||||
file_put_contents($authorizedKeysFile, $publicKey . "\n", FILE_APPEND);
|
||||
chmod($authorizedKeysFile, 0600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Testing SSH connection...");
|
||||
return $this->testConnection($node);
|
||||
}
|
||||
|
||||
private function testConnection(HostingNode $node): int
|
||||
{
|
||||
try {
|
||||
$node->refresh();
|
||||
$provider = app(\App\Services\Hosting\Providers\SharedNodeProvider::class);
|
||||
|
||||
// Use reflection to call private connect method for testing
|
||||
$reflection = new \ReflectionClass($provider);
|
||||
$method = $reflection->getMethod('connect');
|
||||
$method->setAccessible(true);
|
||||
$ssh = $method->invoke($provider, $node);
|
||||
|
||||
if ($ssh === null) {
|
||||
$this->warn("Node is using local execution (no SSH). This means the SSH key is not configured properly.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output = $ssh->exec('whoami');
|
||||
$this->info("SSH connection successful! Connected as: " . trim($output));
|
||||
|
||||
// Verify mysql access
|
||||
$mysqlTest = $ssh->exec('mysql -e "SELECT 1" 2>&1');
|
||||
if (str_contains($mysqlTest, '1')) {
|
||||
$this->info("MySQL access via auth_socket: OK");
|
||||
} else {
|
||||
$this->warn("MySQL access via auth_socket: FAILED ({$mysqlTest})");
|
||||
$this->warn("MySQL may not be installed or auth_socket may not be configured for root.");
|
||||
}
|
||||
|
||||
// Verify runuser access
|
||||
$runuserTest = $ssh->exec('runuser -u nobody -- whoami 2>&1');
|
||||
if (trim($runuserTest) === 'nobody') {
|
||||
$this->info("runuser access: OK");
|
||||
} else {
|
||||
$this->warn("runuser access: FAILED");
|
||||
}
|
||||
|
||||
$ssh->disconnect();
|
||||
$this->info("");
|
||||
$this->info("Node #{$node->id} is fully configured for SSH access.");
|
||||
return self::SUCCESS;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->error("SSH connection failed: " . $e->getMessage());
|
||||
$this->line("");
|
||||
$this->line("Ensure:");
|
||||
$this->line(" 1. SSH is running on the node ({$node->ip_address}:{$node->ssh_port})");
|
||||
$this->line(" 2. Root login is permitted (PermitRootLogin yes in sshd_config)");
|
||||
$this->line(" 3. The public key is installed in /root/.ssh/authorized_keys");
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use App\Services\Hosting\HostingResourcePolicyService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SyncHostingAccountUsageCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:sync-account-usage {--account=}';
|
||||
|
||||
protected $description = 'Sync per-account shared hosting usage from hosting nodes.';
|
||||
|
||||
private const BYTES_PER_GB = 1073741824;
|
||||
|
||||
public function handle(
|
||||
SharedNodeProvider $sharedNodeProvider,
|
||||
NodeCapacityService $capacityService,
|
||||
HostingResourcePolicyService $resourcePolicyService,
|
||||
): int {
|
||||
$accounts = HostingAccount::query()
|
||||
->with(['product', 'node', 'databases', 'user', 'latestOrder'])
|
||||
->where('type', 'shared')
|
||||
->whereIn('status', ['active', 'provisioning', 'suspended'])
|
||||
->when($this->option('account'), fn ($query, $accountId) => $query->where('id', $accountId))
|
||||
->get();
|
||||
|
||||
$touchedNodeIds = [];
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$usage = $sharedNodeProvider->getUsageStats($account);
|
||||
$requestedDiskGb = $account->requestedDiskGb();
|
||||
|
||||
$account->update([
|
||||
'allocated_disk_gb' => $requestedDiskGb,
|
||||
'disk_used_bytes' => (int) ($usage['disk_used_bytes'] ?? $account->disk_used_bytes),
|
||||
'inode_count' => (int) ($usage['inode_count'] ?? $account->inode_count),
|
||||
'cpu_usage_percent' => $usage['cpu_usage_percent'] ?? $account->cpu_usage_percent,
|
||||
'memory_used_mb' => (int) ($usage['memory_used_mb'] ?? $account->memory_used_mb),
|
||||
'process_count' => (int) ($usage['process_count'] ?? $account->process_count),
|
||||
'io_usage_mb' => $usage['io_usage_mb'] ?? $account->io_usage_mb,
|
||||
'last_usage_sync_at' => now(),
|
||||
]);
|
||||
|
||||
$resourcePolicyService->process($account->fresh(['product', 'node', 'databases', 'user', 'latestOrder']));
|
||||
$touchedNodeIds[$account->hosting_node_id] = true;
|
||||
|
||||
$this->line(sprintf(
|
||||
'%s: disk=%dGB inodes=%d cpu=%s%% mem=%dMB proc=%d',
|
||||
$account->username,
|
||||
(int) ceil(((int) ($usage['disk_used_bytes'] ?? 0)) / self::BYTES_PER_GB),
|
||||
(int) ($usage['inode_count'] ?? 0),
|
||||
$usage['cpu_usage_percent'] ?? 'n/a',
|
||||
(int) ($usage['memory_used_mb'] ?? 0),
|
||||
(int) ($usage['process_count'] ?? 0)
|
||||
));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to sync hosting account usage.', [
|
||||
'hosting_account_id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->warn("Failed to sync {$account->username}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_keys($touchedNodeIds) as $nodeId) {
|
||||
$node = HostingNode::query()->find($nodeId);
|
||||
|
||||
if ($node) {
|
||||
$capacityService->refreshNodeResourceTotals($node);
|
||||
$capacityService->checkNode($node);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Synced {$accounts->count()} hosting account(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ContaboApiException extends RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed>|null $payload
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly int $httpStatus,
|
||||
public readonly ?array $payload,
|
||||
string $message,
|
||||
) {
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
public static function fromResponse(int $httpStatus, string $body): self
|
||||
{
|
||||
$payload = json_decode($body, true);
|
||||
$decoded = is_array($payload) ? $payload : null;
|
||||
|
||||
$apiMessage = '';
|
||||
if (is_array($decoded)) {
|
||||
$apiMessage = (string) ($decoded['message'] ?? $decoded['error'] ?? $decoded['statusCode']['message'] ?? '');
|
||||
}
|
||||
|
||||
$message = $apiMessage !== ''
|
||||
? $apiMessage
|
||||
: "Contabo API error (HTTP {$httpStatus})";
|
||||
|
||||
return new self($httpStatus, $decoded, $message);
|
||||
}
|
||||
|
||||
public function isPaymentRequired(): bool
|
||||
{
|
||||
if ($this->httpStatus === 402) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::messageIndicatesPaymentRequired($this->getMessage())
|
||||
|| self::messageIndicatesPaymentRequired(json_encode($this->payload) ?: '');
|
||||
}
|
||||
|
||||
public static function messageIndicatesPaymentRequired(string $text): bool
|
||||
{
|
||||
if ($text === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (bool) preg_match(
|
||||
'/\b402\b|payment\s+required|insufficient\s+(?:account\s+)?balance|not\s+enough\s+(?:credit|funds)|account\s+balance|additional\s+pay(?:ed|ment)\s+service|out\s+of\s+funds/i',
|
||||
$text
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function connect(Request $request): RedirectResponse
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $request->query('redirect', route('hosting.dashboard')));
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]);
|
||||
|
||||
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$authLogin = 'https://'.config('app.auth_domain').'/login';
|
||||
|
||||
if ($request->filled('error') || ! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$user = User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
|
||||
// Accept any pending team invites for this email on first sign-in.
|
||||
\App\Models\HostingTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->intended($request->session()->pull('sso.intended', route('hosting.dashboard')));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$home = 'https://'.config('app.platform_domain');
|
||||
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
|
||||
|
||||
return redirect()->away($endSession);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if ($this->shouldLogoutForMailbox($request)) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
// SLO chain: if the central sequencer passed a (ladill.com) return URL,
|
||||
// continue the top-level redirect chain to the next app; else acknowledge.
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function shouldLogoutForMailbox(Request $request): bool
|
||||
{
|
||||
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
|
||||
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower((string) $user->email) !== $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Account — Wallet, Billing, Settings. Money is read from the one platform
|
||||
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
|
||||
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WalletPaymentService $wallet,
|
||||
private BillingClient $billing,
|
||||
private MailboxClient $mailboxes,
|
||||
private IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** Wallet: balance + email spend summary + add-funds link. */
|
||||
public function wallet(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
return view('email.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
|
||||
public function billing(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
$paidMailboxes = [];
|
||||
$monthlyTotalMinor = 0;
|
||||
try {
|
||||
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
|
||||
if (! ($m['is_paid'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
|
||||
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
|
||||
$monthlyTotalMinor += $m['price_minor'];
|
||||
$paidMailboxes[] = $m;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('email.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'paidMailboxes' => $paidMailboxes,
|
||||
'monthlyTotalMinor' => $monthlyTotalMinor,
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
|
||||
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
|
||||
|
||||
return view('email.account.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
]);
|
||||
}
|
||||
|
||||
public function linkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'mailbox_address' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->identity->linkMailbox(
|
||||
(string) $account->public_id,
|
||||
$data['mailbox_address'],
|
||||
);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
|
||||
if ($connectUrl === '') {
|
||||
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
|
||||
}
|
||||
|
||||
return redirect()->away($connectUrl);
|
||||
}
|
||||
|
||||
public function unlinkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
try {
|
||||
$this->identity->unlinkMailbox((string) $account->public_id);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
|
||||
}
|
||||
|
||||
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
|
||||
private function mailboxLinkContext(?User $account): array
|
||||
{
|
||||
$linkStatus = ['show_reminder' => false];
|
||||
$mailboxOptions = [];
|
||||
|
||||
if (! $account?->public_id) {
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => false,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
|
||||
}
|
||||
|
||||
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|
||||
|| ($linkStatus['show_reminder'] ?? false)
|
||||
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
|
||||
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
|
||||
{
|
||||
if ($linkStatus['linked_mailbox'] ?? null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = strtolower(trim($account->email ?? ''));
|
||||
if ($email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($mailboxOptions as $mailbox) {
|
||||
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count($mailboxOptions) > 0;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
/** @return array<string, mixed> */
|
||||
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
|
||||
{
|
||||
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
|
||||
|
||||
return [
|
||||
'show_reminder' => $needsLink,
|
||||
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
|
||||
'linked_mailbox' => null,
|
||||
'account_email' => $account->email,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
EmailSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'default_quota_mb' => $data['default_quota_mb'] ?? null,
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:array<string,mixed>} [balanceMinor, ledger]
|
||||
*/
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->wallet->balanceMinor(ladill_account()),
|
||||
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [0, []];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Ladill Email — Afia chat endpoint. Grounds the assistant in the user's live
|
||||
* mailboxes/domains so answers are specific.
|
||||
*/
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'history' => ['nullable', 'array', 'max:20'],
|
||||
'history.*.role' => ['nullable', 'string'],
|
||||
'history.*.text' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (! $afia->enabled()) {
|
||||
return response()->json(['message' => 'Afia is not available right now.'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> Live email context for grounding. */
|
||||
private function context(): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
if (! $account) {
|
||||
return ['signed_in' => 'no'];
|
||||
}
|
||||
|
||||
$pid = (string) $account->public_id;
|
||||
$ctx = ['signed_in' => 'yes'];
|
||||
|
||||
try {
|
||||
$mailboxes = app(MailboxClient::class)->forUser($pid);
|
||||
$ctx['mailboxes_total'] = count($mailboxes);
|
||||
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$domains = app(EmailDomainClient::class)->forUser($pid);
|
||||
$ctx['domains_total'] = count($domains);
|
||||
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill Email API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
|
||||
* now: account + mailbox listing). The plaintext token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('email.account.developers', [
|
||||
'tokens' => $request->user()->tokens()->latest()->get(),
|
||||
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
|
||||
'newToken' => session('new_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
|
||||
|
||||
return redirect()->route('account.developers')
|
||||
->with('new_token', $token->plainTextToken)
|
||||
->with('success', 'Token created — copy it now, it won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** Email domain setup (add → publish DNS → verify), via the §4-D API. */
|
||||
class DomainsController extends Controller
|
||||
{
|
||||
public function __construct(private EmailDomainClient $domains) {}
|
||||
|
||||
private function pid(): string
|
||||
{
|
||||
return (string) ladill_account()->public_id;
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$domains = [];
|
||||
$error = null;
|
||||
try {
|
||||
$domains = $this->domains->forUser($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Could not load your domains.';
|
||||
}
|
||||
|
||||
return view('email.domains.index', compact('domains', 'error'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['domain' => ['required', 'string', 'max:253']]);
|
||||
|
||||
try {
|
||||
$domain = $this->domains->create($this->pid(), strtolower(trim($data['domain'])));
|
||||
|
||||
return redirect()->route('email.domains.show', $domain['id'])
|
||||
->with('success', 'Domain added — publish the DNS records below, then verify.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return back()->with('error', 'Could not add that domain. It may already exist.');
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$domain = $this->domains->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.domains.index')->with('error', 'Domain not found.');
|
||||
}
|
||||
|
||||
return view('email.domains.show', ['domain' => $domain]);
|
||||
}
|
||||
|
||||
public function verify(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$domain = $this->domains->verify($this->pid(), $id);
|
||||
$ok = $domain['active'] ?? false;
|
||||
|
||||
return redirect()->route('email.domains.show', $id)
|
||||
->with($ok ? 'success' : 'error', $ok ? 'Domain verified — you can now create mailboxes.' : 'Not verified yet. DNS can take time to propagate.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Verification failed. Try again shortly.');
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->domains->delete($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not remove that domain.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.domains.index')->with('success', 'Domain removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MailboxLinkBannerController extends Controller
|
||||
{
|
||||
public function dismiss(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->put('mailbox_link_banner_dismissed', true);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** My Mailboxes — list/create/manage, via the §4 Mailbox API. */
|
||||
class MailboxesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private MailboxClient $mailboxes,
|
||||
private EmailDomainClient $domains,
|
||||
private WalletPaymentService $wallet,
|
||||
) {}
|
||||
|
||||
private function pid(): string
|
||||
{
|
||||
return (string) ladill_account()->public_id;
|
||||
}
|
||||
|
||||
/** Storage tiers + the default (free) plan. Pricing is purely per-tier. */
|
||||
private function quota(): array
|
||||
{
|
||||
return [
|
||||
'tiers' => MailboxPricing::tiers(),
|
||||
'default_mb' => MailboxPricing::defaultQuotaMb(),
|
||||
'currency' => config('email.currency', 'GHS'),
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$mailboxes = [];
|
||||
$error = null;
|
||||
try {
|
||||
$mailboxes = $this->mailboxes->forUser($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Could not load your mailboxes.';
|
||||
}
|
||||
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$mailboxes = array_values(array_filter($mailboxes, fn ($m) => str_contains(
|
||||
mb_strtolower(($m['address'] ?? '').' '.($m['display_name'] ?? '')), $needle
|
||||
)));
|
||||
}
|
||||
|
||||
return view('email.mailboxes.index', ['mailboxes' => $mailboxes, 'error' => $error, 'q' => $q]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$domains = [];
|
||||
try {
|
||||
$domains = $this->domains->verified($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('email.mailboxes.create', [
|
||||
'domains' => $domains,
|
||||
'quota' => $this->quota(),
|
||||
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email_domain_id' => ['required', 'integer'],
|
||||
'local_part' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9._%+-]+$/i'],
|
||||
'display_name' => ['required', 'string', 'max:160'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:190', 'confirmed'],
|
||||
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
|
||||
if (! MailboxPricing::isValidQuota((int) $value)) {
|
||||
$fail('Choose a valid storage plan.');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
$user = ladill_account();
|
||||
$quotaMb = (int) $data['quota_mb'];
|
||||
$price = MailboxPricing::priceMinorFor($quotaMb); // 1 GB = 0 (free forever)
|
||||
$isPaid = $price > 0;
|
||||
$reference = 'mbx_'.Str::lower(Str::random(20));
|
||||
|
||||
// Paid tiers are wallet-funded (debit first); insufficient → top up.
|
||||
if ($isPaid) {
|
||||
if (! $this->wallet->canAfford($user, $price)) {
|
||||
return back()->withInput()
|
||||
->with('error', 'Your wallet balance is too low for this storage plan. Please top up and try again.')
|
||||
->with('topup', true);
|
||||
}
|
||||
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_create', 'Mailbox '.strtolower($data['local_part']))) {
|
||||
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$mailbox = $this->mailboxes->create(
|
||||
$this->pid(),
|
||||
(int) $data['email_domain_id'],
|
||||
strtolower(trim($data['local_part'])),
|
||||
$data['display_name'],
|
||||
$data['password'],
|
||||
$quotaMb,
|
||||
$isPaid,
|
||||
$isPaid ? $reference : null,
|
||||
);
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $mailbox['id'])
|
||||
->with('success', 'Mailbox '.$mailbox['address'].' is being set up.');
|
||||
} catch (\Throwable $e) {
|
||||
// Provisioning failed after a charge — refund so the customer isn't out of pocket.
|
||||
if ($isPaid) {
|
||||
$this->wallet->refund($user, $price, $reference, 'Mailbox creation failed');
|
||||
}
|
||||
$msg = $e instanceof \Illuminate\Http\Client\RequestException
|
||||
? (string) ($e->response?->json('message') ?? 'Could not create the mailbox.')
|
||||
: 'Could not create the mailbox.';
|
||||
report($e);
|
||||
|
||||
return back()->withInput()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
return view('email.mailboxes.show', ['mailbox' => $mailbox]);
|
||||
}
|
||||
|
||||
/** Upgrade form — pick a larger storage plan for an existing mailbox. */
|
||||
public function upgrade(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
$currentMb = (int) ($mailbox['quota_mb'] ?? 0);
|
||||
// Only offer plans larger than the current one.
|
||||
$tiers = array_values(array_filter(MailboxPricing::tiers(), fn ($t) => $t['mb'] > $currentMb));
|
||||
|
||||
if (empty($tiers)) {
|
||||
return redirect()->route('email.mailboxes.show', $id)->with('error', 'This mailbox is already on the largest plan.');
|
||||
}
|
||||
|
||||
return view('email.mailboxes.upgrade', [
|
||||
'mailbox' => $mailbox,
|
||||
'currentMb' => $currentMb,
|
||||
'tiers' => $tiers,
|
||||
'defaultMb' => $tiers[0]['mb'],
|
||||
'currency' => config('email.currency', 'GHS'),
|
||||
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Apply the upgrade — charge the new tier, then change the quota. */
|
||||
public function upgradeStore(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
|
||||
if (! MailboxPricing::isValidQuota((int) $value)) {
|
||||
$fail('Choose a valid storage plan.');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
$user = ladill_account();
|
||||
$newMb = (int) $data['quota_mb'];
|
||||
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
if ($newMb <= (int) ($mailbox['quota_mb'] ?? 0)) {
|
||||
return back()->withInput()->with('error', 'Choose a larger plan than your current one.');
|
||||
}
|
||||
|
||||
$price = MailboxPricing::priceMinorFor($newMb);
|
||||
$isPaid = $price > 0;
|
||||
$reference = 'mbxup_'.Str::lower(Str::random(20));
|
||||
|
||||
if ($isPaid) {
|
||||
if (! $this->wallet->canAfford($user, $price)) {
|
||||
return back()->withInput()
|
||||
->with('error', 'Your wallet balance is too low for this plan. Please top up and try again.')
|
||||
->with('topup', true);
|
||||
}
|
||||
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_upgrade', 'Upgrade '.($mailbox['address'] ?? 'mailbox').' to '.MailboxPricing::label($newMb))) {
|
||||
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mailboxes->updateQuota($this->pid(), $id, $newMb, $isPaid, $isPaid ? $reference : null);
|
||||
} catch (\Throwable $e) {
|
||||
if ($isPaid) {
|
||||
$this->wallet->refund($user, $price, $reference, 'Mailbox upgrade failed');
|
||||
}
|
||||
report($e);
|
||||
|
||||
return back()->withInput()->with('error', 'Could not upgrade the mailbox.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $id)
|
||||
->with('success', 'Upgraded to '.MailboxPricing::label($newMb).'.');
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['password' => ['required', 'string', 'min:8', 'max:190', 'confirmed']]);
|
||||
|
||||
try {
|
||||
$this->mailboxes->resetPassword($this->pid(), $id, $data['password']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not update the password.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $id)->with('success', 'Password updated.');
|
||||
}
|
||||
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->mailboxes->delete($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not delete the mailbox.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.index')->with('success', 'Mailbox deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private MailboxClient $mailboxes,
|
||||
private EmailDomainClient $domains,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$pid = (string) ladill_account()->public_id;
|
||||
$mailboxes = [];
|
||||
$domains = [];
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$mailboxes = $this->mailboxes->forUser($pid);
|
||||
$domains = $this->domains->forUser($pid);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Some data could not be loaded right now.';
|
||||
}
|
||||
|
||||
return view('email.dashboard', [
|
||||
'mailboxCount' => count($mailboxes),
|
||||
'domainCount' => count($domains),
|
||||
'verifiedDomainCount' => collect($domains)->where('active', true)->count(),
|
||||
'recent' => collect($mailboxes)->take(5)->all(),
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Ladill Email — Team. Invite teammates to an account and manage their roles;
|
||||
* members can then manage that account's mailboxes & domains. Members accept by
|
||||
* signing into Email with the invited email (SSO). Owner and admins can manage.
|
||||
*/
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = EmailTeamMember::query()
|
||||
->where('account_id', $account->id)
|
||||
->with('member')
|
||||
->orderBy('status')->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('email.account.team', [
|
||||
'account' => $account,
|
||||
'members' => $members,
|
||||
'canManage' => $this->canManage($request),
|
||||
'isOwner' => $request->user()->id === $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:admin,member'],
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$email = strtolower($validated['email']);
|
||||
|
||||
if ($email === strtolower($account->email)) {
|
||||
return back()->withErrors(['email' => 'The owner is already on the account.']);
|
||||
}
|
||||
|
||||
$member = EmailTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
|
||||
$member->role = $validated['role'];
|
||||
if (! $member->exists) {
|
||||
$member->status = EmailTeamMember::STATUS_INVITED;
|
||||
$member->token = Str::random(40);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
|
||||
EmailTeamMember::linkPendingInvitesFor($existing);
|
||||
}
|
||||
|
||||
return back()->with('success', $email.' invited.');
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, EmailTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
|
||||
$member->update(['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', 'Role updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, EmailTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Member removed.');
|
||||
}
|
||||
|
||||
/** Switch the acting account (own, or one you're a member of). */
|
||||
public function switchAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate(['account' => ['required', 'integer']]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', (int) $validated['account']);
|
||||
|
||||
return redirect()->route('email.dashboard');
|
||||
}
|
||||
|
||||
private function canManage(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$account = ladill_account();
|
||||
|
||||
if ($user->id === $account->id) {
|
||||
return true; // owner
|
||||
}
|
||||
|
||||
return $user->memberships()
|
||||
->where('account_id', $account->id)
|
||||
->where('role', EmailTeamMember::ROLE_ADMIN)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Account — Wallet, Billing, Settings. Money is read from the one platform
|
||||
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
|
||||
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WalletPaymentService $wallet,
|
||||
private BillingClient $billing,
|
||||
private MailboxClient $mailboxes,
|
||||
private IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** Wallet: balance + email spend summary + add-funds link. */
|
||||
public function wallet(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
return view('hosting.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
|
||||
public function billing(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
$paidMailboxes = [];
|
||||
$monthlyTotalMinor = 0;
|
||||
try {
|
||||
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
|
||||
if (! ($m['is_paid'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
|
||||
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
|
||||
$monthlyTotalMinor += $m['price_minor'];
|
||||
$paidMailboxes[] = $m;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('hosting.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'paidMailboxes' => $paidMailboxes,
|
||||
'monthlyTotalMinor' => $monthlyTotalMinor,
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
|
||||
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
|
||||
|
||||
return view('hosting.account.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
]);
|
||||
}
|
||||
|
||||
public function linkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'mailbox_address' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->identity->linkMailbox(
|
||||
(string) $account->public_id,
|
||||
$data['mailbox_address'],
|
||||
);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
|
||||
if ($connectUrl === '') {
|
||||
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
|
||||
}
|
||||
|
||||
return redirect()->away($connectUrl);
|
||||
}
|
||||
|
||||
public function unlinkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
try {
|
||||
$this->identity->unlinkMailbox((string) $account->public_id);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
|
||||
}
|
||||
|
||||
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
|
||||
private function mailboxLinkContext(?User $account): array
|
||||
{
|
||||
$linkStatus = ['show_reminder' => false];
|
||||
$mailboxOptions = [];
|
||||
|
||||
if (! $account?->public_id) {
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => false,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
|
||||
}
|
||||
|
||||
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|
||||
|| ($linkStatus['show_reminder'] ?? false)
|
||||
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
|
||||
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
|
||||
{
|
||||
if ($linkStatus['linked_mailbox'] ?? null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = strtolower(trim($account->email ?? ''));
|
||||
if ($email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($mailboxOptions as $mailbox) {
|
||||
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count($mailboxOptions) > 0;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
/** @return array<string, mixed> */
|
||||
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
|
||||
{
|
||||
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
|
||||
|
||||
return [
|
||||
'show_reminder' => $needsLink,
|
||||
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
|
||||
'linked_mailbox' => null,
|
||||
'account_email' => $account->email,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
EmailSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'default_quota_mb' => $data['default_quota_mb'] ?? null,
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:array<string,mixed>} [balanceMinor, ledger]
|
||||
*/
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->wallet->balanceMinor(ladill_account()),
|
||||
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [0, []];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Ladill Email — Afia chat endpoint. Grounds the assistant in the user's live
|
||||
* mailboxes/domains so answers are specific.
|
||||
*/
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'history' => ['nullable', 'array', 'max:20'],
|
||||
'history.*.role' => ['nullable', 'string'],
|
||||
'history.*.text' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (! $afia->enabled()) {
|
||||
return response()->json(['message' => 'Afia is not available right now.'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> Live email context for grounding. */
|
||||
private function context(): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
if (! $account) {
|
||||
return ['signed_in' => 'no'];
|
||||
}
|
||||
|
||||
$pid = (string) $account->public_id;
|
||||
$ctx = ['signed_in' => 'yes'];
|
||||
|
||||
try {
|
||||
$mailboxes = app(MailboxClient::class)->forUser($pid);
|
||||
$ctx['mailboxes_total'] = count($mailboxes);
|
||||
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$domains = app(EmailDomainClient::class)->forUser($pid);
|
||||
$ctx['domains_total'] = count($domains);
|
||||
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill Email API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
|
||||
* now: account + mailbox listing). The plaintext token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('hosting.account.developers', [
|
||||
'tokens' => $request->user()->tokens()->latest()->get(),
|
||||
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
|
||||
'newToken' => session('new_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
|
||||
|
||||
return redirect()->route('account.developers')
|
||||
->with('new_token', $token->plainTextToken)
|
||||
->with('success', 'Token created — copy it now, it won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Services\Hosting\ResellerClubHostingService;
|
||||
use App\Services\ResellerClub\ResellerClubCustomerService;
|
||||
use App\Services\ResellerClub\ResellerClubProductService;
|
||||
use App\Support\ResellerClubLegacy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostingController extends Controller
|
||||
{
|
||||
public function index(Request $request, ResellerClubHostingService $hosting): RedirectResponse
|
||||
{
|
||||
return redirect()->route('hosting.single-domain');
|
||||
}
|
||||
|
||||
public function show(
|
||||
Request $request,
|
||||
HostingOrder $hostingOrder,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubProductService $products
|
||||
): View
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$remoteDetails = null;
|
||||
if ($hostingOrder->rc_order_id && $hosting->isConfigured()) {
|
||||
$result = $hosting->getOrderDetails($hostingOrder->rc_order_id);
|
||||
if ($result['success'] ?? false) {
|
||||
$remoteDetails = $result['order'];
|
||||
// Sync latest data
|
||||
$hosting->syncOrderToLocal($result['order']['raw'] ?? $result['order'], $request->user()->id, $hostingOrder->domain_id);
|
||||
$hostingOrder->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return view('hosting.show', [
|
||||
'order' => $hostingOrder,
|
||||
'remoteDetails' => $remoteDetails,
|
||||
'renewalOptions' => $this->renewalOptionsForOrder($hostingOrder, $products),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(
|
||||
Request $request,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubCustomerService $customers
|
||||
): RedirectResponse {
|
||||
$user = $request->user();
|
||||
|
||||
if (! $hosting->isConfigured()) {
|
||||
return redirect()->route('hosting.index')->with('error', 'Hosting service is not configured.');
|
||||
}
|
||||
|
||||
$resolved = $customers->resolveCustomerForUser($user);
|
||||
if (! ($resolved['success'] ?? false)) {
|
||||
return redirect()->route('hosting.index')
|
||||
->with('error', $resolved['message'] ?? 'Could not prepare your ResellerClub customer.');
|
||||
}
|
||||
|
||||
$synced = $hosting->syncCustomerOrders((string) $resolved['customer_id'], $user->id);
|
||||
|
||||
return redirect()->route('hosting.index')
|
||||
->with('success', "Synced {$synced} hosting order(s) from ResellerClub.");
|
||||
}
|
||||
|
||||
public function order(
|
||||
Request $request,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubCustomerService $customers
|
||||
): JsonResponse {
|
||||
$request->validate([
|
||||
'domain_name' => 'required|string|max:253',
|
||||
'plan_id' => 'required|string',
|
||||
'months' => 'sometimes|integer|min:1|max:120',
|
||||
]);
|
||||
|
||||
if (! $hosting->isConfigured()) {
|
||||
return response()->json(['message' => 'Hosting service is not available at this time.'], 503);
|
||||
}
|
||||
|
||||
$domainName = strtolower(trim($request->input('domain_name')));
|
||||
$planId = $request->input('plan_id');
|
||||
$months = $request->integer('months', 1);
|
||||
|
||||
$resolved = $customers->resolveCustomerForUser($request->user());
|
||||
if (! ($resolved['success'] ?? false)) {
|
||||
return response()->json([
|
||||
'message' => $resolved['message'] ?? 'Could not prepare your ResellerClub customer.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $hosting->orderHosting($domainName, $planId, (string) $resolved['customer_id'], $months);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Hosting order failed.'], 422);
|
||||
}
|
||||
|
||||
// Fetch details and save locally
|
||||
$order = null;
|
||||
if (! empty($result['order_id'])) {
|
||||
$details = $hosting->getOrderDetails($result['order_id']);
|
||||
if ($details['success'] ?? false) {
|
||||
$order = $hosting->syncOrderToLocal(
|
||||
$details['order']['raw'] ?? $details['order'],
|
||||
$request->user()->id
|
||||
);
|
||||
} else {
|
||||
$order = HostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'domain_name' => $domainName,
|
||||
'rc_order_id' => $result['order_id'],
|
||||
'plan_name' => $planId,
|
||||
'status' => HostingOrder::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Hosting order placed successfully.',
|
||||
'order' => $order?->only('id', 'domain_name', 'status', 'rc_order_id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetPanelPassword(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'string', 'min:9', 'max:16', 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[~*!@$#%_+.\?:,{}]).+$/'],
|
||||
]);
|
||||
|
||||
if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) {
|
||||
return response()->json(['message' => 'Hosting service is not available.'], 503);
|
||||
}
|
||||
|
||||
$result = $hosting->changePanelPassword($hostingOrder->rc_order_id, $validated['password']);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Could not update the cPanel password.'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'cPanel password updated successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function renew(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'months' => 'sometimes|integer|min:1|max:120',
|
||||
]);
|
||||
|
||||
if (! ResellerClubLegacy::renewalsEnabled()) {
|
||||
return response()->json(['message' => ResellerClubLegacy::renewalsDisabledMessage()], 503);
|
||||
}
|
||||
|
||||
if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) {
|
||||
return response()->json(['message' => 'Hosting service is not available.'], 503);
|
||||
}
|
||||
|
||||
$result = $hosting->renewOrder($hostingOrder->rc_order_id, $request->integer('months', 1));
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Renewal failed.'], 422);
|
||||
}
|
||||
|
||||
// Re-sync details
|
||||
$details = $hosting->getOrderDetails($hostingOrder->rc_order_id);
|
||||
if ($details['success'] ?? false) {
|
||||
$hosting->syncOrderToLocal($details['order']['raw'] ?? $details['order'], $request->user()->id, $hostingOrder->domain_id);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Hosting renewed successfully.']);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): RedirectResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($hostingOrder->status === HostingOrder::STATUS_CANCELLED) {
|
||||
$redirectRoute = $this->productRouteForOrder($hostingOrder);
|
||||
$hostingOrder->delete();
|
||||
|
||||
return redirect()->route($redirectRoute)
|
||||
->with('success', 'Cancelled hosting order deleted.');
|
||||
}
|
||||
|
||||
if ($hostingOrder->rc_order_id && $hosting->isConfigured()) {
|
||||
$result = $hosting->deleteOrder($hostingOrder->rc_order_id);
|
||||
if (! $result['success']) {
|
||||
return redirect()->route('hosting.show', $hostingOrder)
|
||||
->with('error', $result['message'] ?? 'Could not cancel hosting at this time.');
|
||||
}
|
||||
}
|
||||
|
||||
$hostingOrder->update(['status' => HostingOrder::STATUS_CANCELLED]);
|
||||
|
||||
return redirect()->route($this->productRouteForOrder($hostingOrder))
|
||||
->with('success', 'Hosting order cancelled.');
|
||||
}
|
||||
|
||||
private function productRouteForOrder(HostingOrder $hostingOrder): string
|
||||
{
|
||||
return match ((string) $hostingOrder->hosting_type) {
|
||||
HostingOrder::TYPE_MULTI_DOMAIN => 'hosting.multi-domain',
|
||||
HostingOrder::TYPE_RESELLER => 'hosting.multi-domain',
|
||||
default => 'hosting.single-domain',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{months: string, label: string, total: ?string, monthly: ?string}>
|
||||
*/
|
||||
private function renewalOptionsForOrder(HostingOrder $hostingOrder, ResellerClubProductService $products): array
|
||||
{
|
||||
$category = match ((string) $hostingOrder->hosting_type) {
|
||||
HostingOrder::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingOrder::TYPE_RESELLER => 'reseller-hosting',
|
||||
default => 'single-domain-hosting',
|
||||
};
|
||||
|
||||
$packages = collect((array) ($products->formDefinition($category)['packages'] ?? []));
|
||||
$planId = (string) ($hostingOrder->plan_id ?? '');
|
||||
$planName = strtolower(trim((string) $hostingOrder->plan_name));
|
||||
|
||||
$package = $packages->first(function (array $item) use ($planId, $planName): bool {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
$label = strtolower(trim((string) ($item['label'] ?? '')));
|
||||
|
||||
return ($planId !== '' && $value === $planId)
|
||||
|| ($planName !== '' && $label !== '' && $label === $planName);
|
||||
});
|
||||
|
||||
if (! is_array($package)) {
|
||||
return collect(['1', '3', '6', '12', '24', '36'])
|
||||
->map(fn (string $months) => [
|
||||
'months' => $months,
|
||||
'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'),
|
||||
'total' => null,
|
||||
'monthly' => null,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
$priceField = ! empty((array) ($package['renew_prices'] ?? [])) ? 'renew_prices' : 'prices';
|
||||
$totalField = ! empty((array) ($package['renew_totals'] ?? [])) ? 'renew_totals' : 'totals';
|
||||
|
||||
return collect(array_keys((array) ($package[$priceField] ?? [])))
|
||||
->sortBy(fn (string $months) => (int) $months)
|
||||
->values()
|
||||
->map(fn (string $months) => [
|
||||
'months' => $months,
|
||||
'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'),
|
||||
'total' => (string) (($package[$totalField][$months] ?? null) ?: ''),
|
||||
'monthly' => (string) (($package[$priceField][$months] ?? null) ?: ''),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Models\HostingPlanChange;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\RcServiceOrder;
|
||||
use App\Services\Hosting\HostingOrderFulfillmentService;
|
||||
use App\Services\Hosting\HostingPlanChangeService;
|
||||
use App\Services\Hosting\HostingRenewalCheckoutService;
|
||||
use App\Services\Hosting\ServerOrderService;
|
||||
use App\Services\Hosting\UpsellRecommendationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostingProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Smart hosting redirect - routes user based on their hosting accounts
|
||||
*/
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get all shared hosting types (not VPS/dedicated)
|
||||
$sharedTypes = [
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN,
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
|
||||
// Get user's hosting accounts (owned + team member access)
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with('product')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get user's hosting orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with(['product', 'hostingAccount'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Combine accounts from orders and direct assignments
|
||||
$allAccounts = $hostingAccounts->merge(
|
||||
$orders->filter(fn ($o) => $o->hostingAccount)->map(fn ($o) => $o->hostingAccount)
|
||||
)->unique('id');
|
||||
|
||||
// No hosting accounts - show selection modal
|
||||
if ($allAccounts->isEmpty()) {
|
||||
return view('hosting.select-type', [
|
||||
'title' => 'Choose Hosting Plan',
|
||||
]);
|
||||
}
|
||||
|
||||
// Single account - redirect directly to panel
|
||||
if ($allAccounts->count() === 1) {
|
||||
$account = $allAccounts->first();
|
||||
return redirect()->route('hosting.panel.index', $account);
|
||||
}
|
||||
|
||||
// Multiple accounts - show list page
|
||||
return view('hosting.accounts-list', [
|
||||
'title' => 'Your Hosting Accounts',
|
||||
'accounts' => $allAccounts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function singleDomain(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_SINGLE_DOMAIN, 'Single Domain Hosting');
|
||||
}
|
||||
|
||||
public function multiDomain(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_MULTI_DOMAIN, 'Multi Domain Hosting');
|
||||
}
|
||||
|
||||
public function wordpress(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_WORDPRESS, 'WordPress Hosting');
|
||||
}
|
||||
|
||||
public function vps(Request $request): View
|
||||
{
|
||||
return $this->renderServerPage($request, HostingProduct::TYPE_VPS, 'VPS');
|
||||
}
|
||||
|
||||
public function dedicated(Request $request): View
|
||||
{
|
||||
return $this->renderServerPage($request, HostingProduct::TYPE_DEDICATED, 'Dedicated Server');
|
||||
}
|
||||
|
||||
private function renderHostingPage(Request $request, string $type, string $title): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// New Hosting Orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'domain', 'hostingAccount', 'hostingNode'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Admin-assigned Hosting Accounts
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with('product')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// RC Hosting Orders (Legacy)
|
||||
$legacyHostingType = $this->getLegacyHostingTypeForType($type);
|
||||
$rcCategory = $this->getRcCategoryForType($type);
|
||||
$rcHostingOrders = collect();
|
||||
$rcServiceOrders = collect();
|
||||
|
||||
if ($legacyHostingType) {
|
||||
$rcHostingOrders = HostingOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('hosting_type', $legacyHostingType)
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($rcCategory) {
|
||||
$rcServiceOrders = RcServiceOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('category', $rcCategory)
|
||||
->visibleToCustomer()
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
$marketingCategory = $this->getMarketingOrderCategory($type);
|
||||
|
||||
// Native form data for in-dashboard ordering
|
||||
$nativeForm = $this->nativeHostingProductForm($type);
|
||||
$userDomains = $this->getUserDomains($user);
|
||||
|
||||
return view('hosting.type', [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'orders' => $orders,
|
||||
'hostingAccounts' => $hostingAccounts,
|
||||
'rcHostingOrders' => $rcHostingOrders,
|
||||
'rcServiceOrders' => $rcServiceOrders,
|
||||
'marketingCategory' => $marketingCategory,
|
||||
'nativeForm' => $nativeForm,
|
||||
'userDomains' => $userDomains,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderServerPage(Request $request, string $type, string $title): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
|
||||
// New Hosting Orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'vpsInstance'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// RC Service Orders (Legacy)
|
||||
$rcCategory = $this->getRcCategoryForType($type);
|
||||
$rcServiceOrders = collect();
|
||||
|
||||
if ($rcCategory) {
|
||||
$rcServiceOrders = RcServiceOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('category', $rcCategory)
|
||||
->visibleToCustomer()
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
$marketingCategory = $this->getMarketingOrderCategory($type);
|
||||
|
||||
// Native form data for in-dashboard ordering
|
||||
$nativeForm = $this->nativeHostingProductForm($type);
|
||||
$userDomains = $this->getUserDomains($user);
|
||||
$products = HostingProduct::query()
|
||||
->active()
|
||||
->visible()
|
||||
->where('type', $type)
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$serverOrderPayloads = [];
|
||||
foreach ($products as $product) {
|
||||
$serverOrderPayloads[$product->id] = $serverOrders->frontendPayload($product);
|
||||
}
|
||||
|
||||
$billingCycles = [
|
||||
CustomerHostingOrder::CYCLE_MONTHLY => 'Monthly',
|
||||
CustomerHostingOrder::CYCLE_QUARTERLY => 'Quarterly (3 months)',
|
||||
CustomerHostingOrder::CYCLE_SEMIANNUAL => 'Semiannual (6 months)',
|
||||
CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)',
|
||||
];
|
||||
|
||||
return view('hosting.server-type', [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'orders' => $orders,
|
||||
'rcServiceOrders' => $rcServiceOrders,
|
||||
'marketingCategory' => $marketingCategory,
|
||||
'nativeForm' => $nativeForm,
|
||||
'serverOrderPayloads' => $serverOrderPayloads,
|
||||
'billingCycles' => $billingCycles,
|
||||
'userDomains' => $userDomains,
|
||||
]);
|
||||
}
|
||||
|
||||
public function showAccount(
|
||||
Request $request,
|
||||
HostingAccount $account,
|
||||
?UpsellRecommendationService $upsells = null,
|
||||
?HostingRenewalCheckoutService $renewals = null
|
||||
): View
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
$upsells ??= app(UpsellRecommendationService::class);
|
||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||
|
||||
$account->load(['product', 'user', 'node', 'sites', 'mailboxes']);
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||
$mailboxCount = $account->mailboxes->count();
|
||||
$extraMailboxCount = $freeEmailAllowance === null ? 0 : max($mailboxCount - $freeEmailAllowance, 0);
|
||||
|
||||
return view('hosting.account', [
|
||||
'account' => $account,
|
||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||
'maxDomains' => $maxDomains,
|
||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
||||
'ownedDomains' => $this->getUserDomains($request->user()),
|
||||
'emailUsage' => [
|
||||
'mailbox_count' => $mailboxCount,
|
||||
'free_allowance' => $freeEmailAllowance,
|
||||
'extra_count' => $extraMailboxCount,
|
||||
'paid_count' => $account->mailboxes->where('is_paid', true)->count(),
|
||||
'extra_total' => round($extraMailboxCount * 5, 2),
|
||||
],
|
||||
'upsellRecommendations' => $upsells->recommendationsForAccount($account),
|
||||
'renewalOptions' => $account->canBeRenewed() ? $renewals->availableRenewalOptions($account) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeAccountPlan(Request $request, HostingAccount $account, HostingPlanChangeService $planChanges): RedirectResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_product_id' => ['required', 'integer', 'exists:hosting_products,id'],
|
||||
]);
|
||||
|
||||
$account->load(['product', 'latestOrder', 'sites', 'databases', 'node']);
|
||||
$targetProduct = HostingProduct::query()->findOrFail($validated['target_product_id']);
|
||||
|
||||
try {
|
||||
$quote = $planChanges->quote($account, $targetProduct);
|
||||
$result = $planChanges->apply($account, $targetProduct, $request->user());
|
||||
} catch (ValidationException $exception) {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->withErrors($exception->errors())
|
||||
->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Unable to change the hosting plan right now. Please try again.');
|
||||
}
|
||||
|
||||
$message = match ($quote['direction']) {
|
||||
HostingPlanChange::DIRECTION_UPGRADE => $quote['charge_amount'] > 0
|
||||
? sprintf(
|
||||
'Plan upgraded to %s. A GHS %s invoice has been added for the prorated difference.',
|
||||
$targetProduct->name,
|
||||
number_format((float) $quote['charge_amount'], 2)
|
||||
)
|
||||
: sprintf('Plan upgraded to %s.', $targetProduct->name),
|
||||
HostingPlanChange::DIRECTION_DOWNGRADE => $quote['credit_amount'] > 0
|
||||
? sprintf(
|
||||
'Plan changed to %s. A GHS %s credit has been recorded for the unused balance.',
|
||||
$targetProduct->name,
|
||||
number_format((float) $quote['credit_amount'], 2)
|
||||
)
|
||||
: sprintf('Plan changed to %s.', $targetProduct->name),
|
||||
default => 'Plan updated successfully.',
|
||||
};
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $result['account'])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function renewAccount(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse|JsonResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$durationMonths = $request->has('duration_months')
|
||||
? (int) $request->input('duration_months')
|
||||
: null;
|
||||
|
||||
try {
|
||||
$checkout = $renewals->beginCheckout(
|
||||
$account,
|
||||
$request->user(),
|
||||
route('hosting.accounts.renew.callback', $account),
|
||||
$durationMonths
|
||||
);
|
||||
} catch (ValidationException $exception) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => $exception->getMessage()], 422);
|
||||
}
|
||||
return back()->withErrors($exception->errors())->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => 'Unable to start renewal payment. Please try again.'], 422);
|
||||
}
|
||||
return back()->with('error', 'Unable to start renewal payment. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['checkout_url' => $checkout['authorization_url']]);
|
||||
}
|
||||
|
||||
return redirect()->away($checkout['authorization_url']);
|
||||
}
|
||||
|
||||
public function renewAccountCallback(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$reference = (string) $request->query('reference', '');
|
||||
if ($reference === '') {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Missing renewal payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$invoice = $renewals->completeCheckout($account, $request->user(), $reference);
|
||||
} catch (ValidationException $exception) {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->withErrors($exception->errors())
|
||||
->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Unable to verify renewal payment. If you were charged, contact support with your payment reference.');
|
||||
}
|
||||
|
||||
$months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('success', "Hosting renewal payment confirmed. Your account has been renewed for {$months} month(s).");
|
||||
}
|
||||
|
||||
public function show(Request $request, CustomerHostingOrder $order): View
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
|
||||
$order->load(['product', 'domain', 'hostingAccount', 'hostingNode', 'vpsInstance']);
|
||||
|
||||
$viewName = $order->product?->requiresContabo() ? 'hosting.server-order' : 'hosting.order';
|
||||
|
||||
return view($viewName, [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:hosting_products,id',
|
||||
'domain_name' => 'required|string|max:255',
|
||||
'billing_cycle' => 'required|in:monthly,quarterly,yearly,biennial',
|
||||
]);
|
||||
|
||||
$product = HostingProduct::findOrFail($validated['product_id']);
|
||||
|
||||
abort_unless($product->is_active && $product->is_visible, 404, 'Product not available');
|
||||
|
||||
$price = $product->getPriceForCycle($validated['billing_cycle']);
|
||||
abort_unless($price !== null, 400, 'Invalid billing cycle for this product');
|
||||
|
||||
$order = CustomerHostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'domain_name' => $validated['domain_name'],
|
||||
'billing_cycle' => $validated['billing_cycle'],
|
||||
'amount_paid' => $price + ($product->setup_fee ?? 0),
|
||||
'currency' => $product->display_currency,
|
||||
'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
]);
|
||||
|
||||
$fulfillment = app(HostingOrderFulfillmentService::class);
|
||||
$fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled'];
|
||||
$customerMessage = $fulfilled
|
||||
? 'Order received. We are setting up your hosting.'
|
||||
: 'Order received. Your order is pending.';
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $customerMessage,
|
||||
'order' => $order->fresh(),
|
||||
'redirect' => route('hosting.orders.show', $order),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('hosting.orders.show', $order)
|
||||
->with('success', $customerMessage);
|
||||
}
|
||||
|
||||
public function storeServer(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:hosting_products,id',
|
||||
'server_name' => 'required|string|max:255',
|
||||
'billing_cycle' => 'required|in:monthly,quarterly,semiannual,yearly',
|
||||
'region' => 'required|string|max:50',
|
||||
'image' => 'required|string|max:255',
|
||||
'license' => 'required|string|max:100',
|
||||
'application' => 'required|string|max:100',
|
||||
'default_user' => 'required|string|max:50',
|
||||
'additional_ip' => 'required|string|max:50',
|
||||
'private_networking' => 'required|string|max:50',
|
||||
'storage_type' => 'required|string|max:50',
|
||||
'object_storage' => 'required|string|max:50',
|
||||
'server_password' => ['required', 'string', 'confirmed', 'regex:'.$serverOrders->passwordPattern()],
|
||||
]);
|
||||
|
||||
$product = HostingProduct::findOrFail($validated['product_id']);
|
||||
|
||||
abort_unless($product->is_active && $product->is_visible, 404, 'Product not available');
|
||||
abort_unless($product->requiresContabo(), 400, 'Invalid product type for server order');
|
||||
|
||||
$selection = $serverOrders->selectionAndQuote($product, $validated);
|
||||
$quote = $selection['quote'];
|
||||
|
||||
$order = CustomerHostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'domain_name' => $validated['server_name'],
|
||||
'billing_cycle' => $validated['billing_cycle'],
|
||||
'amount_paid' => (float) $quote['total'],
|
||||
'currency' => $product->display_currency,
|
||||
'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
'meta' => [
|
||||
'region' => $selection['selection']['region'],
|
||||
'server_order' => [
|
||||
'selection' => $selection['selection'],
|
||||
'selection_summary' => $selection['selection_summary'],
|
||||
'quote' => $quote,
|
||||
'panel_snapshot' => $selection['panel_snapshot'] ?? [],
|
||||
'server_password_encrypted' => encrypt($validated['server_password']),
|
||||
],
|
||||
'server_panel_runtime' => $selection['panel_snapshot'] ?? [],
|
||||
],
|
||||
]);
|
||||
|
||||
$fulfillment = app(HostingOrderFulfillmentService::class);
|
||||
$fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled'];
|
||||
$customerMessage = $fulfilled
|
||||
? 'Order received. We are setting up your server.'
|
||||
: 'Order received. Your order is pending.';
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $customerMessage,
|
||||
'order' => $order->fresh(),
|
||||
'redirect' => route('hosting.orders.show', $order),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('hosting.orders.show', $order)
|
||||
->with('success', $customerMessage);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, CustomerHostingOrder $order): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
abort_unless($order->canBeCancelled(), 400, 'This order cannot be cancelled');
|
||||
|
||||
$order->cancel($request->input('reason'));
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Order cancelled successfully.');
|
||||
}
|
||||
|
||||
private function getLegacyHostingTypeForType(string $type): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => HostingOrder::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => HostingOrder::TYPE_MULTI_DOMAIN,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getRcCategoryForType(string $type): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting',
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting',
|
||||
HostingProduct::TYPE_VPS => 'vps',
|
||||
HostingProduct::TYPE_DEDICATED => 'dedicated-server',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getMarketingOrderCategory(string $type): ?string
|
||||
{
|
||||
// Map internal hosting types to marketing order categories
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting',
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting',
|
||||
HostingProduct::TYPE_VPS => 'vps',
|
||||
HostingProduct::TYPE_DEDICATED => 'dedicated-server',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build native form definition from HostingProduct records.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function nativeHostingProductForm(string $productType): array
|
||||
{
|
||||
$products = HostingProduct::query()
|
||||
->active()
|
||||
->visible()
|
||||
->where('type', $productType)
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
|
||||
$packages = $products->map(function (HostingProduct $product) use ($serverOrders) {
|
||||
if ($product->requiresContabo()) {
|
||||
$payload = $serverOrders->frontendPayload($product);
|
||||
$currency = strtoupper((string) ($payload['currency'] ?? $product->display_currency));
|
||||
$monthly = (float) data_get($payload, 'prices.monthly', 0);
|
||||
$quarterly = (float) data_get($payload, 'prices.quarterly', 0);
|
||||
$semiannual = (float) data_get($payload, 'prices.semiannual', 0);
|
||||
$yearly = (float) data_get($payload, 'prices.yearly', 0);
|
||||
$setupFees = (array) ($payload['setup_fees'] ?? []);
|
||||
|
||||
$prices = [];
|
||||
$totals = [];
|
||||
|
||||
if ($monthly > 0) {
|
||||
$prices['1'] = $currency.' '.number_format($monthly, 2);
|
||||
$totals['1'] = $currency.' '.number_format($monthly + (float) ($setupFees['monthly'] ?? 0), 2);
|
||||
}
|
||||
if ($quarterly > 0) {
|
||||
$prices['3'] = $currency.' '.number_format($quarterly / 3, 2);
|
||||
$totals['3'] = $currency.' '.number_format($quarterly + (float) ($setupFees['quarterly'] ?? 0), 2);
|
||||
}
|
||||
if ($semiannual > 0) {
|
||||
$prices['6'] = $currency.' '.number_format($semiannual / 6, 2);
|
||||
$totals['6'] = $currency.' '.number_format($semiannual + (float) ($setupFees['semiannual'] ?? 0), 2);
|
||||
}
|
||||
if ($yearly > 0) {
|
||||
$prices['12'] = $currency.' '.number_format($yearly / 12, 2);
|
||||
$totals['12'] = $currency.' '.number_format($yearly + (float) ($setupFees['yearly'] ?? 0), 2);
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => (string) $product->id,
|
||||
'label' => $product->name,
|
||||
'summary' => $this->nativeProductSummary($product),
|
||||
'starting_price' => $currency.' '.number_format($monthly, 2),
|
||||
'starting_term' => '1',
|
||||
'starting_label' => $currency.' '.number_format($monthly, 2).'/mo',
|
||||
'prices' => $prices,
|
||||
'totals' => $totals,
|
||||
'pricing_source' => 'contabo_dynamic',
|
||||
];
|
||||
}
|
||||
|
||||
$currency = $product->display_currency;
|
||||
$monthly = $product->getPriceForCycle('monthly');
|
||||
$quarterly = $product->getPriceForCycle('quarterly');
|
||||
$yearly = $product->getPriceForCycle('yearly');
|
||||
$biennial = $product->getPriceForCycle('biennial');
|
||||
|
||||
$prices = [];
|
||||
$totals = [];
|
||||
|
||||
if ($monthly) {
|
||||
$prices['1'] = $currency.' '.number_format($monthly, 2);
|
||||
$totals['1'] = $currency.' '.number_format($monthly, 2);
|
||||
}
|
||||
if ($quarterly) {
|
||||
$prices['3'] = $currency.' '.number_format($quarterly / 3, 2);
|
||||
$totals['3'] = $currency.' '.number_format($quarterly, 2);
|
||||
}
|
||||
if ($yearly) {
|
||||
$prices['12'] = $currency.' '.number_format($yearly / 12, 2);
|
||||
$totals['12'] = $currency.' '.number_format($yearly, 2);
|
||||
}
|
||||
if ($biennial) {
|
||||
$prices['24'] = $currency.' '.number_format($biennial / 24, 2);
|
||||
$totals['24'] = $currency.' '.number_format($biennial, 2);
|
||||
}
|
||||
|
||||
$summary = $this->nativeProductSummary($product);
|
||||
|
||||
return [
|
||||
'value' => (string) $product->id,
|
||||
'label' => $product->name,
|
||||
'summary' => $summary,
|
||||
'starting_price' => $currency.' '.number_format($product->display_price_monthly, 2),
|
||||
'starting_term' => '1',
|
||||
'starting_label' => $currency.' '.number_format($product->display_price_monthly, 2).'/mo',
|
||||
'prices' => $prices,
|
||||
'totals' => $totals,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'packages' => $packages,
|
||||
'has_packages' => $products->isNotEmpty(),
|
||||
'region' => 'Global',
|
||||
'requires_domain' => true,
|
||||
'supports_os' => false,
|
||||
'os_options' => [],
|
||||
'supports_addons' => false,
|
||||
'addon_options' => [],
|
||||
'supports_ssl_toggle' => false,
|
||||
'supports_dedicated_ip' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function nativeProductSummary(HostingProduct $product): ?string
|
||||
{
|
||||
return $product->catalogSummary();
|
||||
}
|
||||
|
||||
private function getUserDomains($user): \Illuminate\Support\Collection
|
||||
{
|
||||
$websiteIds = \App\Models\Website::query()
|
||||
->where('owner_user_id', $user->id)
|
||||
->pluck('id')
|
||||
->merge(
|
||||
\App\Models\WebsiteMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('website_id')
|
||||
)
|
||||
->unique();
|
||||
|
||||
return \App\Models\Domain::query()
|
||||
->where(function ($query) use ($user, $websiteIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
|
||||
if ($websiteIds->isNotEmpty()) {
|
||||
$query->orWhereIn('website_id', $websiteIds);
|
||||
}
|
||||
})
|
||||
->whereNull('hosting_account_id')
|
||||
->whereDoesntHave('hostedSites')
|
||||
->where(function ($query) {
|
||||
$query->where('source', 'purchased')
|
||||
->orWhereNotNull('email_domain_id');
|
||||
})
|
||||
->orderBy('host')
|
||||
->get(['id', 'host', 'status', 'onboarding_state']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\BrowserTerminalSessionManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HostingTerminalSessionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BrowserTerminalSessionManager $sessionManager,
|
||||
) {}
|
||||
|
||||
public function start(Request $request, HostingAccount $account): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
$account->loadMissing('node');
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => 'nullable|string|max:255',
|
||||
'cols' => 'nullable|integer|min:40|max:240',
|
||||
'rows' => 'nullable|integer|min:10|max:80',
|
||||
]);
|
||||
|
||||
$session = $this->sessionManager->createSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
(string) ($validated['path'] ?? '/public_html'),
|
||||
(int) ($validated['cols'] ?? 120),
|
||||
(int) ($validated['rows'] ?? 30),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $session['session_id'],
|
||||
'status' => $session['status'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function read(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
try {
|
||||
$payload = $this->sessionManager->readOutput(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
(int) $request->integer('offset', 0),
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
public function input(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
$input = $this->extractTerminalInput($request);
|
||||
|
||||
if ($input === null) {
|
||||
return response()->json(['message' => 'Invalid terminal input payload.'], 422);
|
||||
}
|
||||
|
||||
if (strlen($input) > 8192) {
|
||||
return response()->json(['message' => 'Terminal input payload is too large.'], 422);
|
||||
}
|
||||
|
||||
if ($input === '') {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sessionManager->appendInput(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
$input,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function resize(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'cols' => 'required|integer|min:40|max:240',
|
||||
'rows' => 'required|integer|min:10|max:80',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->sessionManager->resizeSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
(int) $validated['cols'],
|
||||
(int) $validated['rows'],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function close(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
try {
|
||||
$this->sessionManager->closeSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function extractTerminalInput(Request $request): ?string
|
||||
{
|
||||
$input = $request->input('input');
|
||||
|
||||
if (is_string($input)) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
|
||||
if (! is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (is_array($decoded) && array_key_exists('input', $decoded) && is_string($decoded['input'])) {
|
||||
return $decoded['input'];
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MailboxLinkBannerController extends Controller
|
||||
{
|
||||
public function dismiss(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->put('mailbox_link_banner_dismissed', true);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function index(Request $request): RedirectResponse
|
||||
{
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Services\Hosting\PanelRuntimes\ServerAgentPanelRuntime;
|
||||
use App\Services\Hosting\ServerAgentBootstrapService;
|
||||
use App\Services\Hosting\Providers\ContaboProvider;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ServerPanelController extends Controller
|
||||
{
|
||||
public function show(Request $request, CustomerHostingOrder $order): View
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
$panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', []));
|
||||
|
||||
if ((bool) ($panelRuntime['managed_stack_candidate'] ?? false)) {
|
||||
app(ServerAgentBootstrapService::class)->ensureBootstrap($order);
|
||||
$order->refresh();
|
||||
$panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', []));
|
||||
}
|
||||
|
||||
return view('hosting.server-panel.show', [
|
||||
'order' => $order,
|
||||
'panelState' => (array) data_get($order->meta, 'server_panel', []),
|
||||
'panelRuntime' => $panelRuntime,
|
||||
'selectionSummary' => (array) data_get($order->meta, 'server_order.selection_summary', []),
|
||||
'serverAgent' => $order->serverAgent,
|
||||
'serverTasks' => $order->serverTasks()->latest('id')->limit(12)->get(),
|
||||
'serverSites' => $order->serverSites()->latest('updated_at')->limit(6)->get(),
|
||||
'serverDatabases' => $order->serverDatabases()->latest('updated_at')->limit(6)->get(),
|
||||
'serverFiles' => $order->serverFiles()->latest('last_synced_at')->limit(8)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(Request $request, CustomerHostingOrder $order, ContaboProvider $provider): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
$instance = $provider->getInstance($this->providerInstanceId($order));
|
||||
|
||||
abort_if($instance === null, 404, 'Server instance not found.');
|
||||
|
||||
$this->updateOrderState($order, $instance);
|
||||
|
||||
return back()->with('success', 'Server status refreshed.');
|
||||
}
|
||||
|
||||
public function power(Request $request, CustomerHostingOrder $order, string $action, ContaboProvider $provider): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
|
||||
abort_unless(in_array($action, ['start', 'stop', 'reboot'], true), 404);
|
||||
|
||||
$instanceId = $this->providerInstanceId($order);
|
||||
$success = match ($action) {
|
||||
'start' => $provider->startInstance($instanceId),
|
||||
'stop' => $provider->stopInstance($instanceId),
|
||||
'reboot' => $provider->rebootInstance($instanceId),
|
||||
};
|
||||
|
||||
if (! $success) {
|
||||
return back()->with('error', 'Server action failed. Please try again.');
|
||||
}
|
||||
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$panelState = (array) ($meta['server_panel'] ?? []);
|
||||
$panelState['last_action'] = [
|
||||
'name' => $action,
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
$panelState['power_status'] = match ($action) {
|
||||
'stop' => 'off',
|
||||
default => 'on',
|
||||
};
|
||||
|
||||
$order->update([
|
||||
'meta' => array_merge($meta, ['server_panel' => $panelState]),
|
||||
]);
|
||||
|
||||
return back()->with('success', ucfirst($action).' request sent successfully.');
|
||||
}
|
||||
|
||||
public function queueDomain(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
'document_root' => ['nullable', 'string', 'max:255'],
|
||||
'php_version' => ['nullable', 'string', 'in:8.0,8.1,8.2,8.3,8.4'],
|
||||
]);
|
||||
|
||||
$runtime->queueDomainTask(
|
||||
$order,
|
||||
$validated['domain'],
|
||||
$validated['document_root'] ?? null,
|
||||
$validated['php_version'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Domain task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueDatabase(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_]+$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueDatabaseTask($order, $validated['name']);
|
||||
|
||||
return back()->with('success', 'Database task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueSsl(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueSslTask($order, $validated['domain']);
|
||||
|
||||
return back()->with('success', 'SSL task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueFile(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'operation' => ['required', 'string', 'in:list,read,write'],
|
||||
'path' => ['required', 'string', 'max:1000'],
|
||||
'content' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$runtime->queueFileTask(
|
||||
$order,
|
||||
$validated['operation'],
|
||||
$validated['path'],
|
||||
$validated['content'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', 'File task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueSiteDelete(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueSiteDeleteTask($order, $validated['domain']);
|
||||
|
||||
return back()->with('success', 'Site removal task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queuePhp(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
'php_version' => ['required', 'string', 'in:8.0,8.1,8.2,8.3,8.4'],
|
||||
]);
|
||||
|
||||
$runtime->queuePhpTask($order, $validated['domain'], $validated['php_version']);
|
||||
|
||||
return back()->with('success', 'PHP configuration task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueCron(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'operation' => ['required', 'string', 'in:list,upsert,remove'],
|
||||
'name' => ['nullable', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'],
|
||||
'expression' => ['nullable', 'string', 'max:120'],
|
||||
'command' => ['nullable', 'string', 'max:1000'],
|
||||
'user' => ['nullable', 'string', 'in:web,root,www-data,nginx,wwwrun,apache'],
|
||||
]);
|
||||
|
||||
if (($validated['operation'] ?? '') !== 'list') {
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (($validated['operation'] ?? '') === 'upsert') {
|
||||
$request->validate([
|
||||
'expression' => ['required', 'string', 'max:120'],
|
||||
'command' => ['required', 'string', 'max:1000'],
|
||||
]);
|
||||
}
|
||||
|
||||
$runtime->queueCronTask(
|
||||
$order,
|
||||
$validated['operation'],
|
||||
$validated['name'] ?? null,
|
||||
$validated['expression'] ?? null,
|
||||
$validated['command'] ?? null,
|
||||
$validated['user'] ?? 'web',
|
||||
);
|
||||
|
||||
return back()->with('success', 'Cron task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueLog(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'target' => ['required', 'string', 'in:nginx_access,nginx_error,php_fpm,mariadb,agent_service'],
|
||||
'domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'],
|
||||
'lines' => ['nullable', 'integer', 'min:20', 'max:500'],
|
||||
]);
|
||||
|
||||
if (in_array($validated['target'], ['nginx_access', 'nginx_error'], true)) {
|
||||
$request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
}
|
||||
|
||||
$runtime->queueLogTask(
|
||||
$order,
|
||||
$validated['target'],
|
||||
$validated['domain'] ?? null,
|
||||
(int) ($validated['lines'] ?? 120),
|
||||
);
|
||||
|
||||
return back()->with('success', 'Log read task queued for the server agent.');
|
||||
}
|
||||
|
||||
private function authorizedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
|
||||
$order->load('product');
|
||||
abort_unless($order->product?->requiresContabo(), 404);
|
||||
abort_unless($this->ladillPanelEnabled($order), 404);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function ladillPanelEnabled(CustomerHostingOrder $order): bool
|
||||
{
|
||||
$license = (string) data_get($order->meta, 'server_order.selection.license', '');
|
||||
|
||||
return (string) data_get(config('hosting.server_order.licenses'), $license.'.panel') === 'ladill';
|
||||
}
|
||||
|
||||
private function providerInstanceId(CustomerHostingOrder $order): string
|
||||
{
|
||||
$instanceId = (string) data_get($order->meta, 'contabo_instance_id', '');
|
||||
abort_if($instanceId === '', 404, 'Server instance is not provisioned yet.');
|
||||
|
||||
return $instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $instance
|
||||
*/
|
||||
private function updateOrderState(CustomerHostingOrder $order, array $instance): void
|
||||
{
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$panelState = (array) ($meta['server_panel'] ?? []);
|
||||
|
||||
$panelState = array_merge($panelState, [
|
||||
'instance_status' => $instance['status'] ?? null,
|
||||
'power_status' => $instance['power_status'] ?? null,
|
||||
'region' => $instance['region'] ?? null,
|
||||
'datacenter' => $instance['datacenter'] ?? null,
|
||||
'image' => $instance['image'] ?? null,
|
||||
'last_synced_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'server_ip' => $instance['ip_address'] ?? $order->server_ip,
|
||||
'meta' => array_merge($meta, ['server_panel' => $panelState]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizedManagedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
abort_unless((bool) data_get($order->meta, 'server_panel_runtime.managed_stack_candidate', false), 404);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Ladill Email — Team. Invite teammates to an account and manage their roles;
|
||||
* members can then manage that account's mailboxes & domains. Members accept by
|
||||
* signing into Email with the invited email (SSO). Owner and admins can manage.
|
||||
*/
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = HostingTeamMember::query()
|
||||
->where('account_id', $account->id)
|
||||
->with('member')
|
||||
->orderBy('status')->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('hosting.account.team', [
|
||||
'account' => $account,
|
||||
'members' => $members,
|
||||
'canManage' => $this->canManage($request),
|
||||
'isOwner' => $request->user()->id === $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:admin,member'],
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$email = strtolower($validated['email']);
|
||||
|
||||
if ($email === strtolower($account->email)) {
|
||||
return back()->withErrors(['email' => 'The owner is already on the account.']);
|
||||
}
|
||||
|
||||
$member = HostingTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
|
||||
$member->role = $validated['role'];
|
||||
if (! $member->exists) {
|
||||
$member->status = HostingTeamMember::STATUS_INVITED;
|
||||
$member->token = Str::random(40);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
|
||||
HostingTeamMember::linkPendingInvitesFor($existing);
|
||||
}
|
||||
|
||||
return back()->with('success', $email.' invited.');
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, HostingTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
|
||||
$member->update(['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', 'Role updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, HostingTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Member removed.');
|
||||
}
|
||||
|
||||
/** Switch the acting account (own, or one you're a member of). */
|
||||
public function switchAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate(['account' => ['required', 'integer']]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', (int) $validated['account']);
|
||||
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
|
||||
private function canManage(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$account = ladill_account();
|
||||
|
||||
if ($user->id === $account->id) {
|
||||
return true; // owner
|
||||
}
|
||||
|
||||
return $user->memberships()
|
||||
->where('account_id', $account->id)
|
||||
->where('role', HostingTeamMember::ROLE_ADMIN)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the "acting account" (owner User) the request operates on. Defaults
|
||||
* to the authenticated user; a team member who switched accounts (session
|
||||
* ladill_account) gets that owner, after an access check. Exposed via request
|
||||
* attribute (ladill_account()) and to views.
|
||||
*/
|
||||
class SetActingAccount
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($user = $request->user()) {
|
||||
$accountId = (int) $request->session()->get('ladill_account', $user->id);
|
||||
|
||||
if (! $user->canAccessAccount($accountId)) {
|
||||
$accountId = $user->id;
|
||||
$request->session()->put('ladill_account', $accountId);
|
||||
}
|
||||
|
||||
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
|
||||
|
||||
$request->attributes->set('actingAccount', $account);
|
||||
View::share('actingAccount', $account);
|
||||
View::share('accessibleAccounts', $user->accessibleAccounts());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Hosting\HostingMailboxService;
|
||||
use App\Services\Mail\MailServerProvisioner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DeactivateMailboxJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(public int $mailboxId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(MailServerProvisioner $provisioner, HostingMailboxService $hostingMailboxes): void
|
||||
{
|
||||
$mailbox = Mailbox::query()->find($this->mailboxId);
|
||||
if (! $mailbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
$hostingAccountId = $mailbox->hosting_account_id;
|
||||
|
||||
try {
|
||||
if ($provisioner->isConfigured()) {
|
||||
$provisioner->deactivateMailbox($mailbox);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$mailbox->update(['status' => 'failed']);
|
||||
Log::error('DeactivateMailboxJob: Remote deactivation failed', [
|
||||
'mailbox_id' => $mailbox->id,
|
||||
'address' => $mailbox->address,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$mailbox->delete();
|
||||
|
||||
if ($hostingAccountId) {
|
||||
$account = HostingAccount::query()->with(['product', 'mailboxes'])->find($hostingAccountId);
|
||||
if ($account) {
|
||||
$hostingMailboxes->syncAddonInvoice($account);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainNsCheck;
|
||||
use App\Services\Domain\DomainDkimService;
|
||||
use App\Services\Mail\MailServerProvisioner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MailProvisioningJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(public int $domainId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(MailServerProvisioner $provisioner, DomainDkimService $dkim): void
|
||||
{
|
||||
$domain = Domain::query()->with('mailboxes')->find($this->domainId);
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $domain->isActiveForMail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $provisioner->isConfigured()) {
|
||||
Log::info('MailProvisioningJob: Skipped — mail server DB not configured', ['domain_id' => $domain->id]);
|
||||
return;
|
||||
}
|
||||
|
||||
$key = $dkim->ensureActive($domain);
|
||||
|
||||
$beforeStatus = (string) $domain->status;
|
||||
$beforeState = (string) ($domain->onboarding_state ?? '');
|
||||
|
||||
try {
|
||||
// 1. Provision domain in Postfix/Dovecot DB
|
||||
$provisioner->provisionDomain($domain);
|
||||
|
||||
// 2. Provision each mailbox in Postfix/Dovecot DB
|
||||
foreach ($domain->mailboxes as $mailbox) {
|
||||
try {
|
||||
$provisioner->provisionMailbox($mailbox);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('MailProvisioningJob: Mailbox provision skipped (may need password)', [
|
||||
'email' => $mailbox->address,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deploy DKIM key to mail server
|
||||
$provisioner->deployDkim($domain, $key);
|
||||
|
||||
$this->markReady($domain);
|
||||
$afterStatus = (string) $domain->status;
|
||||
$afterState = (string) ($domain->onboarding_state ?? '');
|
||||
$this->logCheck($domain, 'success', 'Mail stack provisioned via direct DB', [], $beforeStatus, $beforeState, $afterStatus, $afterState);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->markFailed($domain);
|
||||
$afterStatus = (string) $domain->status;
|
||||
$afterState = (string) ($domain->onboarding_state ?? '');
|
||||
$this->logCheck($domain, 'failed', $exception->getMessage(), [], $beforeStatus, $beforeState, $afterStatus, $afterState);
|
||||
Log::error('Mail provisioning failed', ['domain_id' => $domain->id, 'error' => $exception->getMessage()]);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function markReady(Domain $domain): void
|
||||
{
|
||||
$domain->update([
|
||||
'mail_ready_at' => $domain->mail_ready_at ?? now(),
|
||||
'active_at' => $domain->active_at ?? now(),
|
||||
'status' => 'verified',
|
||||
'onboarding_state' => Domain::STATE_ACTIVE,
|
||||
]);
|
||||
}
|
||||
|
||||
private function markFailed(Domain $domain): void
|
||||
{
|
||||
$domain->update([
|
||||
'status' => 'verifying',
|
||||
'onboarding_state' => Domain::STATE_MAIL_READY,
|
||||
]);
|
||||
}
|
||||
|
||||
private function logCheck(Domain $domain, string $result, string $notes, array $payload = []): void
|
||||
{
|
||||
$stateBefore = $stateAfter = (string) ($domain->onboarding_state ?? '');
|
||||
$statusBefore = $statusAfter = (string) $domain->status;
|
||||
|
||||
if (! empty($payload)) {
|
||||
$notes .= ' '.json_encode($payload);
|
||||
}
|
||||
|
||||
DomainNsCheck::create([
|
||||
'domain_id' => $domain->id,
|
||||
'check_type' => 'mail_provision',
|
||||
'result' => $result,
|
||||
'notes' => $notes.(! empty($payload) ? ' '.json_encode($payload) : ''),
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => $statusAfter,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => $stateAfter,
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MigrateHostingAccountJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $timeout = 3600; // 1 hour timeout for large migrations
|
||||
public int $tries = 1; // Don't retry migrations automatically
|
||||
|
||||
public function __construct(
|
||||
public int $accountId,
|
||||
public int $destinationNodeId,
|
||||
public ?int $initiatedBy = null
|
||||
) {}
|
||||
|
||||
public function handle(SharedNodeProvider $provider): void
|
||||
{
|
||||
$account = HostingAccount::with(['node', 'sites', 'databases'])->find($this->accountId);
|
||||
$destinationNode = HostingNode::find($this->destinationNodeId);
|
||||
|
||||
if (!$account) {
|
||||
Log::error("Migration job failed: Account {$this->accountId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$destinationNode) {
|
||||
Log::error("Migration job failed: Destination node {$this->destinationNodeId} not found");
|
||||
return;
|
||||
}
|
||||
|
||||
$sourceNode = $account->node;
|
||||
$sourceNodeId = $sourceNode?->id;
|
||||
|
||||
Log::info("Starting migration job for account {$account->username}", [
|
||||
'account_id' => $account->id,
|
||||
'source_node' => $sourceNode?->name,
|
||||
'destination_node' => $destinationNode->name,
|
||||
'initiated_by' => $this->initiatedBy,
|
||||
]);
|
||||
|
||||
try {
|
||||
// Update account status to migrating
|
||||
$account->update(['status' => 'migrating']);
|
||||
|
||||
// Perform the migration
|
||||
$result = $provider->migrateAccount($account, $destinationNode, function ($message) use ($account) {
|
||||
Log::info("Migration progress [{$account->username}]: {$message}");
|
||||
});
|
||||
|
||||
// Update account with new node
|
||||
DB::transaction(function () use ($account, $destinationNode, $sourceNodeId) {
|
||||
$account->update([
|
||||
'hosting_node_id' => $destinationNode->id,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Update node account counts
|
||||
$destinationNode->incrementAccountCount();
|
||||
|
||||
if ($sourceNodeId) {
|
||||
HostingNode::where('id', $sourceNodeId)->decrement('current_accounts');
|
||||
}
|
||||
});
|
||||
|
||||
Log::info("Migration completed successfully for account {$account->username}", [
|
||||
'account_id' => $account->id,
|
||||
'new_node' => $destinationNode->name,
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error("Migration failed for account {$account->username}: " . $e->getMessage(), [
|
||||
'account_id' => $account->id,
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
// Revert status
|
||||
$account->update(['status' => 'active']);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error("Migration job failed permanently for account {$this->accountId}", [
|
||||
'destination_node' => $this->destinationNodeId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
// Try to revert account status
|
||||
$account = HostingAccount::find($this->accountId);
|
||||
if ($account && $account->status === 'migrating') {
|
||||
$account->update(['status' => 'active']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ContaboInfrastructureProvision;
|
||||
use App\Services\Infrastructure\ContaboInfrastructureProvisioner;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvisionContaboInfrastructureJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 30;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(
|
||||
public ContaboInfrastructureProvision $provision,
|
||||
) {}
|
||||
|
||||
public function handle(ContaboInfrastructureProvisioner $provisioner): void
|
||||
{
|
||||
$provision = $this->provision->fresh();
|
||||
|
||||
if (! $provision) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if ($provision->status === ContaboInfrastructureProvision::STATUS_PENDING) {
|
||||
$provisioner->createContaboInstance($provision);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($provision->status === ContaboInfrastructureProvision::STATUS_WAITING_IP) {
|
||||
if (! $provisioner->pollInstanceIp($provision)) {
|
||||
self::dispatch($provision)->delay(now()->addSeconds(30));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($provision->status === ContaboInfrastructureProvision::STATUS_BOOTSTRAPPING) {
|
||||
self::dispatch($provision)->delay(now()->addMinutes(2));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Contabo infrastructure provision failed', [
|
||||
'provision_id' => $provision->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$provision->markFailed($e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ProvisionDomainSlaveZoneJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 90;
|
||||
|
||||
public function __construct(public int $domainId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(PowerDnsClient $client): void
|
||||
{
|
||||
$domain = Domain::query()->find($this->domainId);
|
||||
if (!$domain || !$domain->isActiveForMail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$masters = config('pdns.slave_masters', []);
|
||||
$client->ensureSlaveZone($domain, $masters);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDkimKey;
|
||||
use App\Models\DomainNsCheck;
|
||||
use App\Jobs\ProvisionDomainSlaveZoneJob;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProvisionDomainZoneJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 5;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(public int $domainId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(PowerDnsClient $client): void
|
||||
{
|
||||
$domain = Domain::query()->find($this->domainId);
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure DKIM keys exist before provisioning zone
|
||||
$this->ensureDkimKeys($domain);
|
||||
|
||||
try {
|
||||
$client->ensureZone($domain);
|
||||
$this->logCheck($domain, 'success', 'PDNS zone provisioned successfully.');
|
||||
ProvisionDomainSlaveZoneJob::dispatch($domain->id);
|
||||
} catch (RequestException $exception) {
|
||||
$this->logCheck($domain, 'failed', $exception->getMessage());
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureDkimKeys(Domain $domain): void
|
||||
{
|
||||
$existing = DomainDkimKey::query()
|
||||
->where('domain_id', $domain->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
$prefix = (string) config('mailinfra.dkim_selector_prefix', 'ladill');
|
||||
$selector = Str::lower(trim($prefix)) ?: 'ladill';
|
||||
$selector .= '1';
|
||||
|
||||
$config = [
|
||||
'digest_alg' => 'sha512',
|
||||
'private_key_bits' => 2048,
|
||||
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||
];
|
||||
|
||||
$res = openssl_pkey_new($config);
|
||||
openssl_pkey_export($res, $privateKey);
|
||||
$details = openssl_pkey_get_details($res);
|
||||
$publicKey = $details['key'] ?? '';
|
||||
|
||||
$publicKey = preg_replace('/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/', '', $publicKey);
|
||||
|
||||
$privatePath = null;
|
||||
if (is_string($privateKey) && trim($privateKey) !== '') {
|
||||
$safeHost = Str::slug($domain->host, '_');
|
||||
$directory = storage_path('app/mail/dkim');
|
||||
File::ensureDirectoryExists($directory);
|
||||
$privatePath = $directory.'/'.$safeHost.'_'.$selector.'.key';
|
||||
File::put($privatePath, $privateKey);
|
||||
}
|
||||
|
||||
DomainDkimKey::query()->create([
|
||||
'domain_id' => $domain->id,
|
||||
'selector' => $selector,
|
||||
'public_key_txt' => $publicKey,
|
||||
'private_key_path' => $privatePath,
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
private function logCheck(Domain $domain, string $result, string $notes): void
|
||||
{
|
||||
DomainNsCheck::create([
|
||||
'domain_id' => $domain->id,
|
||||
'check_type' => 'pdns_provision',
|
||||
'result' => $result,
|
||||
'status_before' => (string) $domain->status,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => (string) ($domain->onboarding_state ?? ''),
|
||||
'state_after' => (string) ($domain->onboarding_state ?? ''),
|
||||
'notes' => $notes,
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvisionHostingAccountJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $timeout = 300; // 5 minutes for SSH operations
|
||||
public array $backoff = [30, 60, 120]; // Retry after 30s, 60s, 120s
|
||||
|
||||
public function __construct(
|
||||
private int $accountId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(SharedNodeProvider $provider, NodeCapacityService $capacityService): void
|
||||
{
|
||||
$account = HostingAccount::with(['node', 'product'])->find($this->accountId);
|
||||
|
||||
if (! $account) {
|
||||
Log::warning('ProvisionHostingAccountJob: Account not found', ['account_id' => $this->accountId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already provisioned or not in pending status
|
||||
if ($account->status !== 'pending' && $account->provisioned_at !== null) {
|
||||
Log::info('ProvisionHostingAccountJob: Already provisioned', ['account_id' => $this->accountId, 'status' => $account->status]);
|
||||
return;
|
||||
}
|
||||
|
||||
$node = $account->node;
|
||||
if (! $node) {
|
||||
Log::error('ProvisionHostingAccountJob: No node assigned', ['account_id' => $this->accountId]);
|
||||
$account->update(['status' => 'failed', 'notes' => 'No hosting node assigned']);
|
||||
return;
|
||||
}
|
||||
|
||||
$product = $account->product;
|
||||
if (! $product) {
|
||||
Log::error('ProvisionHostingAccountJob: No product found', ['account_id' => $this->accountId]);
|
||||
$account->update(['status' => 'failed', 'notes' => 'No hosting product found']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as provisioning
|
||||
$account->update(['status' => 'provisioning']);
|
||||
|
||||
try {
|
||||
$password = \Illuminate\Support\Str::password(16, true, true, false, false);
|
||||
$domain = $account->primary_domain ?? $account->username . '.ladill.com';
|
||||
|
||||
$result = $provider->createAccountOnNode($node, [
|
||||
'username' => $account->username,
|
||||
'password' => $password,
|
||||
'domain' => $domain,
|
||||
'php_version' => '8.4',
|
||||
'disk_quota_mb' => max((int) ($product->disk_gb ?? 0), 1) * 1024,
|
||||
]);
|
||||
|
||||
$docRoot = $result['document_root'] ?? "/home/{$account->username}/public_html";
|
||||
|
||||
$account->update([
|
||||
'status' => 'active',
|
||||
'home_directory' => $result['home_directory'] ?? "/home/{$account->username}",
|
||||
'provisioned_at' => now(),
|
||||
'metadata' => array_merge((array) $account->metadata, [
|
||||
'provisioned_password' => $password, // Store temporarily for admin reference
|
||||
'provisioned_at' => now()->toISOString(),
|
||||
]),
|
||||
]);
|
||||
|
||||
// Apply resource limits
|
||||
$provider->applyAccountResourceProfile($account->fresh(), false);
|
||||
|
||||
// Install WordPress if it's a WordPress hosting product
|
||||
if ($product->type === 'wordpress') {
|
||||
$wpResult = $provider->installWordPress($node, $account->username, $domain, [
|
||||
'document_root' => $docRoot,
|
||||
'admin_email' => $account->user?->email ?? 'admin@' . $domain,
|
||||
'site_title' => $domain,
|
||||
]);
|
||||
|
||||
// Store WordPress credentials in account metadata
|
||||
$account->update([
|
||||
'metadata' => array_merge((array) $account->metadata, [
|
||||
'wp_admin_user' => $wpResult['admin_user'] ?? 'admin',
|
||||
'wp_admin_password' => $wpResult['admin_password'] ?? null,
|
||||
'wp_db_name' => $wpResult['db_name'] ?? null,
|
||||
'wp_db_user' => $wpResult['db_user'] ?? null,
|
||||
'wp_db_password' => $wpResult['db_password'] ?? null,
|
||||
]),
|
||||
]);
|
||||
|
||||
Log::info('ProvisionHostingAccountJob: WordPress installed', [
|
||||
'account_id' => $this->accountId,
|
||||
'domain' => $domain,
|
||||
]);
|
||||
}
|
||||
|
||||
// Refresh node capacity
|
||||
$capacityService->refreshNodeResourceTotals($node);
|
||||
|
||||
Log::info('ProvisionHostingAccountJob: Successfully provisioned', [
|
||||
'account_id' => $this->accountId,
|
||||
'username' => $account->username,
|
||||
'node_id' => $node->id,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('ProvisionHostingAccountJob: Failed to provision', [
|
||||
'account_id' => $this->accountId,
|
||||
'username' => $account->username,
|
||||
'error' => $exception->getMessage(),
|
||||
'trace' => $exception->getTraceAsString(),
|
||||
]);
|
||||
|
||||
$account->update([
|
||||
'status' => 'failed',
|
||||
'notes' => 'Provisioning failed: ' . $exception->getMessage(),
|
||||
]);
|
||||
|
||||
// Re-throw to trigger retry
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('ProvisionHostingAccountJob: Job failed after all retries', [
|
||||
'account_id' => $this->accountId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$account = HostingAccount::find($this->accountId);
|
||||
if ($account) {
|
||||
$account->update([
|
||||
'status' => 'failed',
|
||||
'notes' => 'Provisioning failed after retries: ' . $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Exceptions\ContaboApiException;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingNode;
|
||||
use App\Models\ProvisioningQueueItem;
|
||||
use App\Services\Hosting\HostingResourcePolicyService;
|
||||
use App\Services\Hosting\NodeCapacityService;
|
||||
use App\Services\Hosting\Providers\ContaboProvider;
|
||||
use App\Services\Hosting\ServerOrderService;
|
||||
use App\Services\Hosting\ServerAgentBootstrapService;
|
||||
use App\Services\Hosting\ServerAgentInstallerService;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProvisionHostingOrderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
public int $backoff = 300;
|
||||
|
||||
public function __construct(
|
||||
public CustomerHostingOrder $order
|
||||
) {}
|
||||
|
||||
public function handle(
|
||||
NodeCapacityService $capacityService,
|
||||
HostingResourcePolicyService $resourcePolicyService,
|
||||
SharedNodeProvider $sharedProvider,
|
||||
ContaboProvider $contaboProvider,
|
||||
ServerOrderService $serverOrders,
|
||||
ServerAgentBootstrapService $serverAgentBootstrap,
|
||||
ServerAgentInstallerService $serverAgentInstaller,
|
||||
): void {
|
||||
if ($this->order->status !== CustomerHostingOrder::STATUS_APPROVED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->order->markAsProvisioning();
|
||||
|
||||
$product = $this->order->product;
|
||||
$queueItem = $this->getOrCreateQueueItem();
|
||||
|
||||
try {
|
||||
$queueItem->markAsProcessing();
|
||||
|
||||
if ($product->isSharedHosting()) {
|
||||
$this->provisionSharedHosting($capacityService, $resourcePolicyService, $sharedProvider, $queueItem);
|
||||
} elseif ($product->isVps()) {
|
||||
$this->provisionVps($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem);
|
||||
} elseif ($product->isDedicated()) {
|
||||
$this->provisionDedicated($contaboProvider, $serverOrders, $serverAgentBootstrap, $serverAgentInstaller, $queueItem);
|
||||
} else {
|
||||
throw new \Exception("Unknown product category: {$product->category}");
|
||||
}
|
||||
|
||||
$queueItem->markAsCompleted();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->shouldHoldForAdmin($e)) {
|
||||
$message = $e->getMessage();
|
||||
$queueItem->markAsFailed($message);
|
||||
$this->order->fresh()->holdForAdminProvisioning(
|
||||
$message,
|
||||
$e instanceof ContaboApiException ? $e->httpStatus : null,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$queueItem->markAsFailed($e->getMessage());
|
||||
|
||||
if ($queueItem->canRetry()) {
|
||||
$this->release($this->backoff);
|
||||
} else {
|
||||
$this->order->markAsFailed($e->getMessage());
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldHoldForAdmin(\Throwable $e): bool
|
||||
{
|
||||
if ($e instanceof ContaboApiException) {
|
||||
return $e->isPaymentRequired();
|
||||
}
|
||||
|
||||
$product = $this->order->product;
|
||||
if ($product?->requiresContabo() !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ContaboApiException::messageIndicatesPaymentRequired($e->getMessage());
|
||||
}
|
||||
|
||||
private function provisionSharedHosting(
|
||||
NodeCapacityService $capacityService,
|
||||
HostingResourcePolicyService $resourcePolicyService,
|
||||
SharedNodeProvider $sharedProvider,
|
||||
ProvisioningQueueItem $queueItem
|
||||
): void {
|
||||
$queueItem->addLog('Finding available shared hosting node...');
|
||||
|
||||
$product = $this->order->product;
|
||||
$requestedDiskGb = (int) ($product->disk_gb ?? 0);
|
||||
$segment = $product->preferredNodeSegment();
|
||||
$node = $capacityService->findAvailableNodeForProduct($product);
|
||||
|
||||
if (!$node) {
|
||||
throw new \Exception('No available shared hosting nodes. Please provision a new node.');
|
||||
}
|
||||
|
||||
$queueItem->addLog("Selected node: {$node->name} ({$node->ip_address}) [segment={$segment}, requested_disk={$requestedDiskGb}GB]");
|
||||
|
||||
// Generate username
|
||||
$username = $this->generateUsername($this->order->domain_name);
|
||||
$password = Str::password(16, true, true, false, false);
|
||||
|
||||
$queueItem->addLog("Creating account: {$username}");
|
||||
$resourceLimits = $resourcePolicyService->defaultLimitsForProduct($product);
|
||||
|
||||
// Create account on node via SSH
|
||||
$result = $sharedProvider->createAccountOnNode($node, [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'domain' => $this->order->domain_name,
|
||||
'email' => $this->order->user->email ?: $this->order->guest_email,
|
||||
'disk_quota_mb' => max($requestedDiskGb, 1) * 1024,
|
||||
'bandwidth_mb' => (($product->bandwidth_gb ?? 100) > 0 ? $product->bandwidth_gb : 100) * 1024,
|
||||
]);
|
||||
|
||||
// Create hosting account record
|
||||
$account = HostingAccount::create([
|
||||
'user_id' => $this->order->user_id,
|
||||
'hosting_node_id' => $node->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'username' => $username,
|
||||
'primary_domain' => $this->order->domain_name,
|
||||
'type' => HostingAccount::TYPE_SHARED,
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
|
||||
'allocated_disk_gb' => $requestedDiskGb,
|
||||
'disk_used_bytes' => 0,
|
||||
'bandwidth_used_bytes' => 0,
|
||||
'cpu_limit_percent' => $resourceLimits['cpu_limit_percent'],
|
||||
'memory_limit_mb' => $resourceLimits['memory_limit_mb'],
|
||||
'process_limit' => $resourceLimits['process_limit'],
|
||||
'io_limit_mb' => $resourceLimits['io_limit_mb'],
|
||||
'inode_limit' => $resourceLimits['inode_limit'],
|
||||
'resource_status' => HostingAccount::RESOURCE_STATUS_ACTIVE,
|
||||
'uploads_restricted' => false,
|
||||
'is_flagged' => false,
|
||||
'warning_count' => 0,
|
||||
'suspended_at' => null,
|
||||
'provisioned_at' => now(),
|
||||
'metadata' => [
|
||||
'assigned_duration_months' => $this->order->getCycleLengthInMonths(),
|
||||
'initial_password' => $password,
|
||||
'provisioned_from_order_id' => $this->order->id,
|
||||
'provisioned_on_node' => $node->name,
|
||||
],
|
||||
'resource_limits' => array_merge([
|
||||
'disk_gb' => $requestedDiskGb,
|
||||
'bandwidth_gb' => $product->bandwidth_gb,
|
||||
'max_domains' => $product->max_domains,
|
||||
'max_databases' => $product->max_databases,
|
||||
], $resourceLimits),
|
||||
]);
|
||||
|
||||
HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $this->order->domain_id,
|
||||
'domain' => $this->order->domain_name,
|
||||
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
|
||||
'type' => 'primary',
|
||||
'status' => 'active',
|
||||
'php_version' => '8.4',
|
||||
'ssl_enabled' => false,
|
||||
]);
|
||||
|
||||
$sharedProvider->applyAccountResourceProfile($account, false);
|
||||
|
||||
$node = $capacityService->refreshNodeResourceTotals($node);
|
||||
$capacityService->checkNode($node);
|
||||
|
||||
// Install WordPress if it's a WordPress hosting plan
|
||||
$wpCredentials = [];
|
||||
if ($product->type === 'wordpress') {
|
||||
$queueItem->addLog('Installing WordPress...');
|
||||
$docRoot = $result['document_root'] ?? "/home/{$username}/public_html";
|
||||
$wpCredentials = $sharedProvider->installWordPress($node, $username, $this->order->domain_name, [
|
||||
'document_root' => $docRoot,
|
||||
'admin_email' => $this->order->user->email ?: $this->order->guest_email,
|
||||
'site_title' => $this->order->domain_name,
|
||||
]);
|
||||
}
|
||||
|
||||
$queueItem->addLog('Provisioning completed successfully.');
|
||||
|
||||
// Update order with provisioning details
|
||||
$orderMeta = array_merge($this->order->meta ?? [], [
|
||||
'provisioned_on_node' => $node->name,
|
||||
'node_segment' => $node->segment,
|
||||
'requested_disk_gb' => $requestedDiskGb,
|
||||
'initial_password' => $password,
|
||||
]);
|
||||
|
||||
// Add WordPress credentials if installed
|
||||
if (!empty($wpCredentials)) {
|
||||
$orderMeta['wp_admin_user'] = $wpCredentials['admin_user'] ?? 'admin';
|
||||
$orderMeta['wp_admin_password'] = $wpCredentials['admin_password'] ?? null;
|
||||
$orderMeta['wp_admin_email'] = $wpCredentials['admin_email'] ?? null;
|
||||
$orderMeta['wp_db_name'] = $wpCredentials['db_name'] ?? null;
|
||||
$orderMeta['wp_db_user'] = $wpCredentials['db_user'] ?? null;
|
||||
$orderMeta['wp_db_password'] = $wpCredentials['db_password'] ?? null;
|
||||
}
|
||||
|
||||
$this->order->markAsActive([
|
||||
'hosting_node_id' => $node->id,
|
||||
'hosting_account_id' => $account->id,
|
||||
'server_username' => $username,
|
||||
'server_ip' => $node->ip_address,
|
||||
'control_panel_url' => "https://{$this->order->domain_name}/cpanel",
|
||||
'expires_at' => $this->order->calculateExpiryDate(),
|
||||
'meta' => $orderMeta,
|
||||
]);
|
||||
}
|
||||
|
||||
private function provisionVps(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void
|
||||
{
|
||||
$queueItem->addLog('Provisioning VPS via Contabo API...');
|
||||
|
||||
$product = $this->order->product;
|
||||
|
||||
if (!$product->contabo_product_id) {
|
||||
throw new \Exception('Product does not have a Contabo product ID configured.');
|
||||
}
|
||||
|
||||
$provisioning = $this->serverProvisioningConfig($serverOrders, 'VPS');
|
||||
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
|
||||
$bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh());
|
||||
$provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap);
|
||||
$queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.');
|
||||
}
|
||||
$result = $contaboProvider->createInstance($provisioning);
|
||||
|
||||
$queueItem->addLog("VPS created with instance ID: {$result['instance_id']}");
|
||||
foreach ($provisioning['manual_follow_up'] ?? [] as $item) {
|
||||
$queueItem->addLog("Manual follow-up required for {$item}.");
|
||||
}
|
||||
|
||||
$this->order->markAsActive([
|
||||
'server_ip' => $result['ip_address'] ?? null,
|
||||
'server_username' => (string) ($provisioning['default_user'] ?? 'root'),
|
||||
'control_panel_url' => $this->serverControlPanelUrl($provisioning),
|
||||
'expires_at' => $this->order->calculateExpiryDate(),
|
||||
'meta' => array_merge($this->order->meta ?? [], [
|
||||
'contabo_instance_id' => $result['instance_id'],
|
||||
'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []),
|
||||
'server_panel' => [
|
||||
'panel' => $provisioning['panel'] ?? null,
|
||||
'instance_status' => $result['status'] ?? null,
|
||||
'power_status' => 'on',
|
||||
'region' => $result['region'] ?? null,
|
||||
'datacenter' => $result['datacenter'] ?? null,
|
||||
'image' => $result['image'] ?? null,
|
||||
'last_synced_at' => now()->toIso8601String(),
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
|
||||
$serverAgentBootstrap->ensureBootstrap($this->order->fresh());
|
||||
}
|
||||
}
|
||||
|
||||
private function provisionDedicated(ContaboProvider $contaboProvider, ServerOrderService $serverOrders, ServerAgentBootstrapService $serverAgentBootstrap, ServerAgentInstallerService $serverAgentInstaller, ProvisioningQueueItem $queueItem): void
|
||||
{
|
||||
$queueItem->addLog('Provisioning Dedicated Server via Contabo API...');
|
||||
|
||||
$product = $this->order->product;
|
||||
|
||||
if (!$product->contabo_product_id) {
|
||||
throw new \Exception('Product does not have a Contabo product ID configured.');
|
||||
}
|
||||
|
||||
$provisioning = $this->serverProvisioningConfig($serverOrders, 'Dedicated');
|
||||
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
|
||||
$bootstrap = $serverAgentBootstrap->ensureBootstrap($this->order->fresh());
|
||||
$provisioning = $serverAgentInstaller->augmentProvisioningConfig($this->order->fresh(), $provisioning, $bootstrap);
|
||||
$queueItem->addLog('Managed stack bootstrap and Ladill server agent install prepared.');
|
||||
}
|
||||
$result = $contaboProvider->createInstance($provisioning);
|
||||
|
||||
$queueItem->addLog("Dedicated server created with instance ID: {$result['instance_id']}");
|
||||
foreach ($provisioning['manual_follow_up'] ?? [] as $item) {
|
||||
$queueItem->addLog("Manual follow-up required for {$item}.");
|
||||
}
|
||||
|
||||
$this->order->markAsActive([
|
||||
'server_ip' => $result['ip_address'] ?? null,
|
||||
'server_username' => (string) ($provisioning['default_user'] ?? 'root'),
|
||||
'control_panel_url' => $this->serverControlPanelUrl($provisioning),
|
||||
'expires_at' => $this->order->calculateExpiryDate(),
|
||||
'meta' => array_merge($this->order->meta ?? [], [
|
||||
'contabo_instance_id' => $result['instance_id'],
|
||||
'server_panel_runtime' => $provisioning['panel_snapshot'] ?? data_get($this->order->meta, 'server_panel_runtime', []),
|
||||
'server_panel' => [
|
||||
'panel' => $provisioning['panel'] ?? null,
|
||||
'instance_status' => $result['status'] ?? null,
|
||||
'power_status' => 'on',
|
||||
'region' => $result['region'] ?? null,
|
||||
'datacenter' => $result['datacenter'] ?? null,
|
||||
'image' => $result['image'] ?? null,
|
||||
'last_synced_at' => now()->toIso8601String(),
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
if ((bool) ($provisioning['panel_snapshot']['managed_stack_candidate'] ?? false)) {
|
||||
$serverAgentBootstrap->ensureBootstrap($this->order->fresh());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serverProvisioningConfig(ServerOrderService $serverOrders, string $prefix): array
|
||||
{
|
||||
$meta = (array) ($this->order->meta ?? []);
|
||||
$serverOrder = (array) ($meta['server_order'] ?? []);
|
||||
$selection = (array) ($serverOrder['selection'] ?? []);
|
||||
$passwordEncrypted = $serverOrder['server_password_encrypted'] ?? null;
|
||||
|
||||
$config = $serverOrders->provisioningConfig(
|
||||
$this->order->product,
|
||||
$selection,
|
||||
"{$prefix}-{$this->order->id}-{$this->order->domain_name}",
|
||||
$this->order->billing_cycle
|
||||
);
|
||||
|
||||
if (is_string($passwordEncrypted) && $passwordEncrypted !== '') {
|
||||
$config['root_password'] = decrypt($passwordEncrypted);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $provisioning
|
||||
*/
|
||||
private function serverControlPanelUrl(array $provisioning): ?string
|
||||
{
|
||||
if (($provisioning['panel'] ?? null) !== 'ladill') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return route('hosting.server-panel.show', $this->order);
|
||||
}
|
||||
|
||||
private function getOrCreateQueueItem(): ProvisioningQueueItem
|
||||
{
|
||||
$existing = ProvisioningQueueItem::where('customer_hosting_order_id', $this->order->id)->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$provider = match ($this->order->product->category) {
|
||||
'shared' => ProvisioningQueueItem::PROVIDER_SHARED_NODE,
|
||||
'vps' => ProvisioningQueueItem::PROVIDER_CONTABO_VPS,
|
||||
'dedicated' => ProvisioningQueueItem::PROVIDER_CONTABO_DEDICATED,
|
||||
default => ProvisioningQueueItem::PROVIDER_SHARED_NODE,
|
||||
};
|
||||
|
||||
return ProvisioningQueueItem::create([
|
||||
'customer_hosting_order_id' => $this->order->id,
|
||||
'status' => ProvisioningQueueItem::STATUS_QUEUED,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateUsername(string $domain): string
|
||||
{
|
||||
// Remove TLD and special characters
|
||||
$base = preg_replace('/[^a-z0-9]/', '', strtolower(explode('.', $domain)[0]));
|
||||
$base = substr($base, 0, 8);
|
||||
|
||||
// Add random suffix to ensure uniqueness
|
||||
return $base . Str::random(4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\HostedSite;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvisionHostingSslJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 8;
|
||||
public int $timeout = 180; // 3 minutes for certbot
|
||||
public array $backoff = [300, 600, 1800, 3600, 7200, 14400, 21600]; // Retry across typical DNS propagation windows
|
||||
|
||||
public function __construct(
|
||||
private int $siteId,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(SharedNodeProvider $provider): void
|
||||
{
|
||||
$site = HostedSite::with(['account.node', 'account.user'])->find($this->siteId);
|
||||
|
||||
if (! $site) {
|
||||
Log::warning('ProvisionHostingSslJob: Site not found', ['site_id' => $this->siteId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if already has SSL
|
||||
if ($site->ssl_enabled && $site->ssl_status === 'issued') {
|
||||
Log::info('ProvisionHostingSslJob: SSL already active', ['site_id' => $this->siteId, 'domain' => $site->domain]);
|
||||
return;
|
||||
}
|
||||
|
||||
$account = $site->account;
|
||||
if (! $account || ! $account->node) {
|
||||
Log::error('ProvisionHostingSslJob: No account or node', ['site_id' => $this->siteId]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if account is not active
|
||||
if ($account->status !== 'active') {
|
||||
Log::info('ProvisionHostingSslJob: Account not active, skipping SSL', [
|
||||
'site_id' => $this->siteId,
|
||||
'account_status' => $account->status,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$site->update(['ssl_status' => 'provisioning']);
|
||||
|
||||
try {
|
||||
$provider->requestLetsEncryptCertificate($site);
|
||||
|
||||
$site->update([
|
||||
'ssl_enabled' => true,
|
||||
'ssl_status' => 'issued',
|
||||
'ssl_provisioned_at' => now(),
|
||||
'ssl_expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [
|
||||
'site_id' => $this->siteId,
|
||||
'domain' => $site->domain,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
$message = $exception->getMessage();
|
||||
|
||||
// Check if it's a DNS propagation issue (should retry)
|
||||
$isDnsIssue = str_contains($message, 'DNS problem')
|
||||
|| str_contains($message, 'NXDOMAIN')
|
||||
|| str_contains($message, 'no valid A records')
|
||||
|| str_contains($message, 'Timeout');
|
||||
|
||||
$site->update([
|
||||
'ssl_status' => 'failed',
|
||||
'ssl_error' => $message,
|
||||
]);
|
||||
|
||||
Log::error('ProvisionHostingSslJob: Failed to provision SSL', [
|
||||
'site_id' => $this->siteId,
|
||||
'domain' => $site->domain,
|
||||
'error' => $message,
|
||||
'is_dns_issue' => $isDnsIssue,
|
||||
]);
|
||||
|
||||
// Re-throw to trigger retry (especially for DNS issues)
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(\Throwable $exception): void
|
||||
{
|
||||
Log::error('ProvisionHostingSslJob: Job failed after all retries', [
|
||||
'site_id' => $this->siteId,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
$site = HostedSite::find($this->siteId);
|
||||
if ($site) {
|
||||
$site->update([
|
||||
'ssl_status' => 'failed',
|
||||
'ssl_error' => 'SSL provisioning failed after multiple attempts: ' . $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Mailbox;
|
||||
use App\Services\Mail\MailServerProvisioner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProvisionMailboxJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public function __construct(
|
||||
public int $mailboxId,
|
||||
public ?string $encryptedPassword = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(MailServerProvisioner $provisioner): void
|
||||
{
|
||||
$mailbox = Mailbox::query()
|
||||
->with(['domain', 'emailDomain'])
|
||||
->find($this->mailboxId);
|
||||
|
||||
if (! $mailbox || (string) $mailbox->status === 'deleting') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $provisioner->isConfigured()) {
|
||||
Log::info('ProvisionMailboxJob: Skipped — mail server DB not configured', [
|
||||
'mailbox_id' => $mailbox->id,
|
||||
]);
|
||||
$mailbox->update(['status' => 'active']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$password = $this->encryptedPassword !== null
|
||||
? decrypt($this->encryptedPassword)
|
||||
: null;
|
||||
|
||||
try {
|
||||
if ($mailbox->email_domain_id !== null) {
|
||||
$provisioner->provisionStandaloneMailbox($mailbox, $password);
|
||||
} else {
|
||||
$provisioner->provisionMailbox($mailbox, $password);
|
||||
}
|
||||
|
||||
$mailbox->update(['status' => 'active']);
|
||||
} catch (\Throwable $exception) {
|
||||
$mailbox->update(['status' => 'failed']);
|
||||
Log::error('ProvisionMailboxJob: Failed', [
|
||||
'mailbox_id' => $mailbox->id,
|
||||
'address' => $mailbox->address,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainNsCheck;
|
||||
use App\Notifications\SslProvisionedNotification;
|
||||
use App\Services\Ssl\SslProvisioner;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SslProvisioningJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 120;
|
||||
|
||||
public function __construct(public int $domainId)
|
||||
{
|
||||
}
|
||||
|
||||
public function handle(SslProvisioner $provisioner): void
|
||||
{
|
||||
$domain = Domain::query()->find($this->domainId);
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) $domain->status !== 'verified' || (string) $domain->onboarding_state !== Domain::STATE_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) $domain->ssl_status === 'active') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $provisioner->isConfigured()) {
|
||||
Log::info('SslProvisioningJob: Skipped — SSL not configured', ['domain_id' => $domain->id]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$beforeStatus = (string) $domain->status;
|
||||
$beforeState = (string) ($domain->onboarding_state ?? '');
|
||||
|
||||
$domain->update(['ssl_status' => 'provisioning']);
|
||||
|
||||
try {
|
||||
$certOk = $provisioner->provisionCertificate($domain);
|
||||
if (! $certOk) {
|
||||
throw new \RuntimeException('certbot failed for ' . $domain->host);
|
||||
}
|
||||
|
||||
$nginxOk = $provisioner->generateNginxConfig($domain);
|
||||
if (! $nginxOk) {
|
||||
throw new \RuntimeException('Nginx config generation failed for ' . $domain->host);
|
||||
}
|
||||
|
||||
$reloadOk = $provisioner->reloadNginx();
|
||||
if (! $reloadOk) {
|
||||
throw new \RuntimeException('Nginx reload failed after provisioning ' . $domain->host);
|
||||
}
|
||||
|
||||
$domain->update([
|
||||
'ssl_status' => 'active',
|
||||
'ssl_provisioned_at' => now(),
|
||||
'ssl_expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
$this->logCheck($domain, 'success', 'SSL certificate provisioned and Nginx configured', $beforeStatus, $beforeState);
|
||||
|
||||
// Notify user that SSL is active
|
||||
$user = $domain->website?->user;
|
||||
if ($user) {
|
||||
$user->notify(new SslProvisionedNotification($domain->fresh()));
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
$domain->update(['ssl_status' => 'failed']);
|
||||
$this->logCheck($domain, 'failed', $exception->getMessage(), $beforeStatus, $beforeState);
|
||||
Log::error('SslProvisioningJob: Failed', ['domain_id' => $domain->id, 'error' => $exception->getMessage()]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function logCheck(Domain $domain, string $result, string $notes, string $statusBefore, string $stateBefore): void
|
||||
{
|
||||
DomainNsCheck::create([
|
||||
'domain_id' => $domain->id,
|
||||
'check_type' => 'ssl_provision',
|
||||
'result' => $result,
|
||||
'notes' => $notes,
|
||||
'status_before' => $statusBefore,
|
||||
'status_after' => (string) $domain->status,
|
||||
'state_before' => $stateBefore,
|
||||
'state_after' => (string) ($domain->onboarding_state ?? ''),
|
||||
'checked_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class AppInstallation extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'hosted_site_id',
|
||||
'app_type',
|
||||
'app_version',
|
||||
'admin_path',
|
||||
'admin_username',
|
||||
'admin_email',
|
||||
'admin_password_encrypted',
|
||||
'database_name',
|
||||
'database_username',
|
||||
'database_password_encrypted',
|
||||
'install_url',
|
||||
'config',
|
||||
'status',
|
||||
'progress',
|
||||
'progress_message',
|
||||
'current_step',
|
||||
'total_steps',
|
||||
'error_message',
|
||||
'installed_at',
|
||||
'last_updated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'config' => 'array',
|
||||
'installed_at' => 'datetime',
|
||||
'last_updated_at' => 'datetime',
|
||||
'progress' => 'integer',
|
||||
'total_steps' => 'integer',
|
||||
];
|
||||
|
||||
public const APP_WORDPRESS = 'wordpress';
|
||||
public const APP_JOOMLA = 'joomla';
|
||||
public const APP_DRUPAL = 'drupal';
|
||||
public const APP_OPENCART = 'opencart';
|
||||
public const APP_MAGENTO = 'magento';
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostedSite::class, 'hosted_site_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function getAdminPasswordAttribute(): ?string
|
||||
{
|
||||
return $this->admin_password_encrypted ? decrypt($this->admin_password_encrypted) : null;
|
||||
}
|
||||
|
||||
public function getDatabasePasswordAttribute(): ?string
|
||||
{
|
||||
return $this->database_password_encrypted ? decrypt($this->database_password_encrypted) : null;
|
||||
}
|
||||
|
||||
public function getAdminUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->install_url || !$this->admin_path) {
|
||||
return null;
|
||||
}
|
||||
return rtrim($this->install_url, '/') . '/' . ltrim($this->admin_path, '/');
|
||||
}
|
||||
|
||||
public function updateProgress(int $step, int $totalSteps, string $message, ?string $currentStep = null): void
|
||||
{
|
||||
$progress = $totalSteps > 0 ? (int) round(($step / $totalSteps) * 100) : 0;
|
||||
|
||||
$this->update([
|
||||
'progress' => min($progress, 100),
|
||||
'progress_message' => $message,
|
||||
'current_step' => $currentStep,
|
||||
'total_steps' => $totalSteps,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $errorMessage): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'failed',
|
||||
'error_message' => $errorMessage,
|
||||
'progress_message' => 'Installation failed',
|
||||
]);
|
||||
}
|
||||
|
||||
public function markCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => 'active',
|
||||
'progress' => 100,
|
||||
'progress_message' => 'Installation complete',
|
||||
'current_step' => null,
|
||||
'installed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function isInstalling(): bool
|
||||
{
|
||||
return $this->status === 'installing';
|
||||
}
|
||||
|
||||
public function hasFailed(): bool
|
||||
{
|
||||
return $this->status === 'failed';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ContaboInfrastructureProvision extends Model
|
||||
{
|
||||
public const PURPOSE_HOSTING_NODE = 'hosting_node';
|
||||
|
||||
public const PURPOSE_EMAIL_PRIMARY = 'email_primary';
|
||||
|
||||
public const PURPOSE_EMAIL_EXTENSION = 'email_extension';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_CREATING = 'creating';
|
||||
|
||||
public const STATUS_WAITING_IP = 'waiting_ip';
|
||||
|
||||
public const STATUS_BOOTSTRAPPING = 'bootstrapping';
|
||||
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'purpose',
|
||||
'status',
|
||||
'name',
|
||||
'contabo_product_id',
|
||||
'region',
|
||||
'image_id',
|
||||
'contabo_instance_id',
|
||||
'completion_token',
|
||||
'payload',
|
||||
'log',
|
||||
'ssh_public_key',
|
||||
'ssh_private_key',
|
||||
'generated_mail_db_password',
|
||||
'hosting_node_id',
|
||||
'email_server_id',
|
||||
'created_by',
|
||||
'error_message',
|
||||
'instance_created_at',
|
||||
'ip_assigned_at',
|
||||
'completed_at',
|
||||
'failed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'log' => 'array',
|
||||
'instance_created_at' => 'datetime',
|
||||
'ip_assigned_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'ssh_private_key',
|
||||
'generated_mail_db_password',
|
||||
'completion_token',
|
||||
];
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function completionUrl(): string
|
||||
{
|
||||
return url('/api/infrastructure/contabo/callback/'.$this->id.'?token='.$this->completion_token);
|
||||
}
|
||||
|
||||
public function purposeLabel(): string
|
||||
{
|
||||
return match ($this->purpose) {
|
||||
self::PURPOSE_HOSTING_NODE => 'Hosting node',
|
||||
self::PURPOSE_EMAIL_PRIMARY => 'Mail server (primary)',
|
||||
self::PURPOSE_EMAIL_EXTENSION => 'Mail server (extension)',
|
||||
default => $this->purpose,
|
||||
};
|
||||
}
|
||||
|
||||
public function addLog(string $message): void
|
||||
{
|
||||
$log = $this->log ?? [];
|
||||
$log[] = ['at' => now()->toIso8601String(), 'message' => $message];
|
||||
$this->update(['log' => $log]);
|
||||
}
|
||||
|
||||
public function markFailed(string $message): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $message,
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
$this->addLog('Failed: '.$message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class CustomerHostingOrder extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING_APPROVAL = 'pending_approval';
|
||||
public const STATUS_APPROVED = 'approved';
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const CYCLE_MONTHLY = 'monthly';
|
||||
public const CYCLE_QUARTERLY = 'quarterly';
|
||||
public const CYCLE_SEMIANNUAL = 'semiannual';
|
||||
public const CYCLE_YEARLY = 'yearly';
|
||||
public const CYCLE_BIENNIAL = 'biennial';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'guest_email',
|
||||
'hosting_product_id',
|
||||
'domain_id',
|
||||
'domain_name',
|
||||
'billing_cycle',
|
||||
'amount_paid',
|
||||
'currency',
|
||||
'payment_reference',
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'approval_notes',
|
||||
'hosting_node_id',
|
||||
'hosting_account_id',
|
||||
'vps_instance_id',
|
||||
'server_username',
|
||||
'server_ip',
|
||||
'control_panel_url',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
'suspended_at',
|
||||
'cancelled_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_paid' => 'decimal:2',
|
||||
'approved_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'suspended_at' => 'datetime',
|
||||
'cancelled_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class)->withDefault();
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'hosting_product_id');
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function approvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'approved_by');
|
||||
}
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function vpsInstance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(VpsInstance::class);
|
||||
}
|
||||
|
||||
public function provisioningQueue(): HasOne
|
||||
{
|
||||
return $this->hasOne(ProvisioningQueueItem::class);
|
||||
}
|
||||
|
||||
public function serverAgent(): HasOne
|
||||
{
|
||||
return $this->hasOne(ServerAgent::class);
|
||||
}
|
||||
|
||||
public function serverTasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerTask::class);
|
||||
}
|
||||
|
||||
public function serverSites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class);
|
||||
}
|
||||
|
||||
public function serverDatabases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
|
||||
public function serverFiles(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class);
|
||||
}
|
||||
|
||||
public function scopePendingApproval($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING_APPROVAL);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, int $userId)
|
||||
{
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
public function isPendingApproval(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_APPROVAL;
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_APPROVED;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isProvisioning(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PROVISIONING;
|
||||
}
|
||||
|
||||
public function canBeApproved(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_APPROVAL;
|
||||
}
|
||||
|
||||
public function canBeCancelled(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_PENDING_PAYMENT,
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_ACTIVE,
|
||||
self::STATUS_SUSPENDED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(User $admin, ?string $notes = null): bool
|
||||
{
|
||||
if (!$this->canBeApproved()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_APPROVED,
|
||||
'approved_by' => $admin->id,
|
||||
'approved_at' => now(),
|
||||
'approval_notes' => $notes,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function markAsProvisioning(): void
|
||||
{
|
||||
$this->update(['status' => self::STATUS_PROVISIONING]);
|
||||
}
|
||||
|
||||
public function markAsActive(array $provisioningDetails = []): void
|
||||
{
|
||||
$this->update(array_merge([
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'provisioned_at' => now(),
|
||||
], $provisioningDetails));
|
||||
}
|
||||
|
||||
public function markAsFailed(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['failure_reason'] = $reason;
|
||||
$meta['failed_at'] = now()->toIso8601String();
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a paid order to the admin queue after upstream provisioning could not complete
|
||||
* (e.g. Contabo account balance). Customer still sees a generic pending state.
|
||||
*/
|
||||
public function holdForAdminProvisioning(string $reason, ?int $httpStatus = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['provisioning_hold'] = [
|
||||
'reason' => $reason,
|
||||
'http_status' => $httpStatus,
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
unset($meta['failure_reason'], $meta['failed_at']);
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_PENDING_APPROVAL,
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function suspend(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['suspension_reason'] = $reason;
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_SUSPENDED,
|
||||
'suspended_at' => now(),
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(?string $reason = null): void
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$meta['cancellation_reason'] = $reason;
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_CANCELLED,
|
||||
'cancelled_at' => now(),
|
||||
'meta' => $meta,
|
||||
]);
|
||||
}
|
||||
|
||||
public function getCycleLengthInMonths(): int
|
||||
{
|
||||
return match ($this->billing_cycle) {
|
||||
self::CYCLE_MONTHLY => 1,
|
||||
self::CYCLE_QUARTERLY => 3,
|
||||
self::CYCLE_SEMIANNUAL => 6,
|
||||
self::CYCLE_YEARLY => 12,
|
||||
self::CYCLE_BIENNIAL => 24,
|
||||
default => 12,
|
||||
};
|
||||
}
|
||||
|
||||
public function calculateExpiryDate(): \Carbon\Carbon
|
||||
{
|
||||
$startDate = $this->provisioned_at ?? now();
|
||||
return $startDate->copy()->addMonths($this->getCycleLengthInMonths());
|
||||
}
|
||||
|
||||
public function customerFacingStatusLabel(): string
|
||||
{
|
||||
return self::customerFacingStatusLabelFor($this->status);
|
||||
}
|
||||
|
||||
public static function customerFacingStatusLabelFor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
self::STATUS_PENDING_PAYMENT => 'Pending Payment',
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROVISIONING => 'Pending',
|
||||
self::STATUS_ACTIVE => 'Active',
|
||||
self::STATUS_SUSPENDED => 'Suspended',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
self::STATUS_EXPIRED => 'Expired',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
default => 'Pending',
|
||||
};
|
||||
}
|
||||
|
||||
public function isCustomerPending(): bool
|
||||
{
|
||||
return in_array($this->status, [
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROVISIONING,
|
||||
], true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Domain extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const MODE_NS_AUTO = 'ns_auto';
|
||||
public const MODE_MANUAL_DNS = 'manual_dns';
|
||||
public const MODE_RESELLER_AUTO = 'reseller_auto';
|
||||
|
||||
public const STATE_PENDING_NS = 'pending_ns';
|
||||
public const STATE_VERIFYING_NS = 'verifying_ns';
|
||||
public const STATE_CONNECTED = 'connected';
|
||||
public const STATE_MAIL_READY = 'mail_ready';
|
||||
public const STATE_ACTIVE = 'active';
|
||||
public const STATE_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'website_id',
|
||||
'hosting_account_id',
|
||||
'email_domain_id',
|
||||
'email_server_id',
|
||||
'host',
|
||||
'type',
|
||||
'source',
|
||||
'onboarding_mode',
|
||||
'verification_token',
|
||||
'verified_at',
|
||||
'status',
|
||||
'onboarding_state',
|
||||
'dns_mode',
|
||||
'ns_expected',
|
||||
'ns_observed',
|
||||
'ns_checked_at',
|
||||
'connected_at',
|
||||
'mail_ready_at',
|
||||
'active_at',
|
||||
'manual_dns_verified_at',
|
||||
'verification_meta',
|
||||
'ssl_status',
|
||||
'ssl_provisioned_at',
|
||||
'ssl_expires_at',
|
||||
'registrar',
|
||||
'registrar_order_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'verified_at' => 'datetime',
|
||||
'ns_expected' => 'array',
|
||||
'ns_observed' => 'array',
|
||||
'ns_checked_at' => 'datetime',
|
||||
'connected_at' => 'datetime',
|
||||
'mail_ready_at' => 'datetime',
|
||||
'active_at' => 'datetime',
|
||||
'manual_dns_verified_at' => 'datetime',
|
||||
'verification_meta' => 'array',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function emailDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function dnsRecords(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainDnsRecord::class);
|
||||
}
|
||||
|
||||
public function dkimKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainDkimKey::class);
|
||||
}
|
||||
|
||||
public function nsChecks(): HasMany
|
||||
{
|
||||
return $this->hasMany(DomainNsCheck::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class);
|
||||
}
|
||||
|
||||
public function hostedSites(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedSite::class);
|
||||
}
|
||||
|
||||
public function isActiveForMail(): bool
|
||||
{
|
||||
return (string) $this->status === 'verified'
|
||||
&& (string) $this->onboarding_state === self::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainDkimKey extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'selector',
|
||||
'public_key_txt',
|
||||
'private_key_path',
|
||||
'status',
|
||||
'generated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'generated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainDnsRecord extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'name',
|
||||
'type',
|
||||
'value',
|
||||
'ttl',
|
||||
'priority',
|
||||
'source',
|
||||
'status',
|
||||
'last_checked_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ttl' => 'integer',
|
||||
'priority' => 'integer',
|
||||
'last_checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DomainNsCheck extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'domain_id',
|
||||
'check_type',
|
||||
'result',
|
||||
'expected_nameservers',
|
||||
'observed_nameservers',
|
||||
'has_authoritative_answer',
|
||||
'manual_records_total',
|
||||
'manual_records_verified',
|
||||
'status_before',
|
||||
'status_after',
|
||||
'state_before',
|
||||
'state_after',
|
||||
'notes',
|
||||
'checked_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expected_nameservers' => 'array',
|
||||
'observed_nameservers' => 'array',
|
||||
'has_authoritative_answer' => 'boolean',
|
||||
'manual_records_total' => 'integer',
|
||||
'manual_records_verified' => 'integer',
|
||||
'checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class DomainOrder extends Model
|
||||
{
|
||||
public const TYPE_REGISTRATION = 'registration';
|
||||
public const TYPE_TRANSFER = 'transfer';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const REGISTRAR_DYNADOT = 'dynadot';
|
||||
public const REGISTRAR_RESELLERCLUB = 'resellerclub';
|
||||
|
||||
public const STATUS_CART = 'cart';
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PENDING_CUSTOMER_REGISTRATION = 'pending_customer_registration';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const PAYMENT_UNPAID = 'unpaid';
|
||||
public const PAYMENT_PENDING = 'pending';
|
||||
public const PAYMENT_PAID = 'paid';
|
||||
public const PAYMENT_FAILED = 'failed';
|
||||
public const PAYMENT_REFUNDED = 'refunded';
|
||||
|
||||
public const FULFILLMENT_CART = 'cart';
|
||||
public const FULFILLMENT_PAYMENT_PENDING = 'payment_pending';
|
||||
public const FULFILLMENT_PAYMENT_RECEIVED = 'payment_received';
|
||||
public const FULFILLMENT_SUBMITTING = 'submitting';
|
||||
public const FULFILLMENT_AWAITING_REGISTRAR = 'awaiting_registrar';
|
||||
public const FULFILLMENT_FULFILLED = 'fulfilled';
|
||||
public const FULFILLMENT_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'domain_name',
|
||||
'order_type',
|
||||
'registrar',
|
||||
'status',
|
||||
'payment_status',
|
||||
'fulfillment_status',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'years',
|
||||
'payment_reference',
|
||||
'auth_code_encrypted',
|
||||
'contact_info',
|
||||
'nameservers',
|
||||
'registrar_order_id',
|
||||
'meta',
|
||||
'paid_at',
|
||||
'submitted_at',
|
||||
'completed_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => 'integer',
|
||||
'years' => 'integer',
|
||||
'contact_info' => 'array',
|
||||
'nameservers' => 'array',
|
||||
'meta' => 'array',
|
||||
'paid_at' => 'datetime',
|
||||
'submitted_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function setAuthCode(string $authCode): void
|
||||
{
|
||||
$this->auth_code_encrypted = Crypt::encryptString($authCode);
|
||||
}
|
||||
|
||||
public function getAuthCode(): ?string
|
||||
{
|
||||
if (empty($this->auth_code_encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($this->auth_code_encrypted);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getTld(): string
|
||||
{
|
||||
$parts = explode('.', $this->domain_name, 2);
|
||||
return $parts[1] ?? '';
|
||||
}
|
||||
|
||||
public function formatAmount(): string
|
||||
{
|
||||
$amount = $this->amount_minor / 100;
|
||||
$symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency;
|
||||
return $symbol . number_format($amount, 2);
|
||||
}
|
||||
|
||||
public function getOrderTypeLabel(): string
|
||||
{
|
||||
return match ($this->order_type) {
|
||||
self::TYPE_REGISTRATION => 'Registration',
|
||||
self::TYPE_TRANSFER => 'Transfer',
|
||||
self::TYPE_RENEWAL => 'Renewal',
|
||||
default => ucfirst($this->order_type),
|
||||
};
|
||||
}
|
||||
|
||||
public function getStatusLabel(): string
|
||||
{
|
||||
return match ($this->status) {
|
||||
self::STATUS_CART => 'In Cart',
|
||||
self::STATUS_PENDING_PAYMENT => 'Awaiting Payment',
|
||||
self::STATUS_PENDING => 'Pending',
|
||||
self::STATUS_PENDING_CUSTOMER_REGISTRATION => 'Awaiting Registration',
|
||||
self::STATUS_PROCESSING => 'Processing',
|
||||
self::STATUS_COMPLETED => 'Completed',
|
||||
self::STATUS_FAILED => 'Failed',
|
||||
self::STATUS_CANCELLED => 'Cancelled',
|
||||
default => ucfirst($this->status),
|
||||
};
|
||||
}
|
||||
|
||||
public function isInCart(): bool
|
||||
{
|
||||
return in_array($this->status, [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->payment_status === self::PAYMENT_PAID;
|
||||
}
|
||||
|
||||
public function scopeInCart($query)
|
||||
{
|
||||
return $query->whereIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
|
||||
public function scopeForUser($query, User $user)
|
||||
{
|
||||
return $query->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
public function scopePendingFulfillment($query)
|
||||
{
|
||||
return $query->where('payment_status', self::PAYMENT_PAID)
|
||||
->whereIn('fulfillment_status', [
|
||||
self::FULFILLMENT_PAYMENT_RECEIVED,
|
||||
self::FULFILLMENT_SUBMITTING,
|
||||
self::FULFILLMENT_AWAITING_REGISTRAR,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Domain pricing overrides.
|
||||
*
|
||||
* When a TLD has an entry here with is_override=true, the manually set GHS price
|
||||
* will be used instead of the live-converted Dynadot price.
|
||||
*
|
||||
* Prices are stored in pesewas (minor units).
|
||||
*/
|
||||
class DomainPricing extends Model
|
||||
{
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $columnExists = [];
|
||||
|
||||
protected $fillable = [
|
||||
'tld',
|
||||
'register_price',
|
||||
'renew_price',
|
||||
'transfer_price',
|
||||
'currency',
|
||||
'is_override',
|
||||
'is_featured',
|
||||
'is_active',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'register_price' => 'integer',
|
||||
'renew_price' => 'integer',
|
||||
'transfer_price' => 'integer',
|
||||
'is_override' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get active pricing override for a TLD.
|
||||
*/
|
||||
public static function forTld(string $tld): ?self
|
||||
{
|
||||
$tld = ltrim(strtolower(trim($tld)), '.');
|
||||
|
||||
return Cache::remember(
|
||||
"domain_pricing:{$tld}",
|
||||
now()->addHours(1),
|
||||
function () use ($tld) {
|
||||
$query = static::where('tld', $tld);
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active price overrides.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, is_override: bool}>
|
||||
*/
|
||||
public static function getOverrides(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:overrides',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
// Older installs may have used this table only for manual overrides.
|
||||
if (static::hasColumn('is_override')) {
|
||||
$query->where('is_override', true);
|
||||
}
|
||||
|
||||
return $query->get()
|
||||
->keyBy('tld')
|
||||
->map(fn ($p) => [
|
||||
'register' => $p->register_price,
|
||||
'renew' => $p->renew_price,
|
||||
'transfer' => $p->transfer_price,
|
||||
'is_override' => static::hasColumn('is_override') ? (bool) $p->is_override : true,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active TLDs with pricing.
|
||||
*/
|
||||
public static function activeTlds(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:active_tlds',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('sort_order')) {
|
||||
$query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
return $query->orderBy('tld')->pluck('tld')->all();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get featured TLDs for search.
|
||||
*/
|
||||
public static function featuredTlds(): array
|
||||
{
|
||||
return Cache::remember(
|
||||
'domain_pricing:featured_tlds',
|
||||
now()->addHours(1),
|
||||
function () {
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('is_featured')) {
|
||||
$query->where('is_featured', true);
|
||||
}
|
||||
|
||||
if (static::hasColumn('sort_order')) {
|
||||
$query->orderBy('sort_order');
|
||||
}
|
||||
|
||||
$tlds = $query->orderBy('tld')->pluck('tld')->all();
|
||||
|
||||
return $tlds !== [] ? $tlds : static::activeTlds();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing map for multiple TLDs.
|
||||
*
|
||||
* @return array<string, array{register: int, renew: int, transfer: int, currency: string}>
|
||||
*/
|
||||
public static function pricingMap(array $tlds = []): array
|
||||
{
|
||||
$query = static::query();
|
||||
|
||||
if (static::hasColumn('is_active')) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
if (! empty($tlds)) {
|
||||
$query->whereIn('tld', array_map(fn ($t) => ltrim(strtolower($t), '.'), $tlds));
|
||||
}
|
||||
|
||||
return $query->get()->keyBy('tld')->map(fn ($p) => [
|
||||
'register' => $p->register_price,
|
||||
'renew' => $p->renew_price,
|
||||
'transfer' => $p->transfer_price,
|
||||
'currency' => $p->currency,
|
||||
])->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pricing cache.
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
Cache::forget('domain_pricing:active_tlds');
|
||||
Cache::forget('domain_pricing:featured_tlds');
|
||||
Cache::forget('domain_pricing:overrides');
|
||||
|
||||
foreach (static::pluck('tld') as $tld) {
|
||||
Cache::forget("domain_pricing:{$tld}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format price for display.
|
||||
*/
|
||||
public function formatRegisterPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->register_price);
|
||||
}
|
||||
|
||||
public function formatRenewPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->renew_price);
|
||||
}
|
||||
|
||||
public function formatTransferPrice(): string
|
||||
{
|
||||
return $this->formatPrice($this->transfer_price);
|
||||
}
|
||||
|
||||
private function formatPrice(int $amountMinor): string
|
||||
{
|
||||
$symbol = $this->currency === 'GHS' ? 'GH₵' : $this->currency;
|
||||
return $symbol . ' ' . number_format($amountMinor / 100, 2);
|
||||
}
|
||||
|
||||
private static function hasColumn(string $column): bool
|
||||
{
|
||||
$cacheKey = static::class . ':' . $column;
|
||||
|
||||
if (! array_key_exists($cacheKey, self::$columnExists)) {
|
||||
self::$columnExists[$cacheKey] = Schema::hasColumn((new static)->getTable(), $column);
|
||||
}
|
||||
|
||||
return self::$columnExists[$cacheKey];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class EmailDomain extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_VERIFYING = 'verifying';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const METHOD_NAMESERVER = 'nameserver';
|
||||
public const METHOD_DNS_RECORD = 'dns_record';
|
||||
|
||||
public const FREE_MAILBOXES_LIMIT = 5;
|
||||
public const PAID_MAILBOX_PRICE_CEDIS = 5;
|
||||
public const SUBSCRIPTION_MONTHLY_PRICE_CEDIS = 5;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'email_server_id',
|
||||
'domain',
|
||||
'status',
|
||||
'verification_method',
|
||||
'verification_token',
|
||||
'ns_expected',
|
||||
'ns_observed',
|
||||
'ns_checked_at',
|
||||
'verified_at',
|
||||
'mail_ready_at',
|
||||
'free_mailboxes_limit',
|
||||
'paid_mailboxes_count',
|
||||
'dns_records',
|
||||
'dns_verification_status',
|
||||
'dns_last_checked_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ns_expected' => 'array',
|
||||
'ns_observed' => 'array',
|
||||
'ns_checked_at' => 'datetime',
|
||||
'verified_at' => 'datetime',
|
||||
'mail_ready_at' => 'datetime',
|
||||
'dns_records' => 'array',
|
||||
'dns_verification_status' => 'array',
|
||||
'dns_last_checked_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function emailServer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailServer::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class, 'email_domain_id');
|
||||
}
|
||||
|
||||
public function dkimKeys(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailDomainDkimKey::class);
|
||||
}
|
||||
|
||||
public function activeDkimKey(): HasOne
|
||||
{
|
||||
return $this->hasOne(EmailDomainDkimKey::class)->where('status', 'deployed')->latest();
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function isVerifying(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_VERIFYING;
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isSuspended(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_SUSPENDED;
|
||||
}
|
||||
|
||||
public function isReadyForMail(): bool
|
||||
{
|
||||
// Domain is ready for mail if it's active (verified)
|
||||
// mail_ready_at is set when infrastructure is provisioned, but we allow
|
||||
// mailbox creation as soon as domain is verified
|
||||
return $this->isActive();
|
||||
}
|
||||
|
||||
public function usesNameserverVerification(): bool
|
||||
{
|
||||
return $this->verification_method === self::METHOD_NAMESERVER;
|
||||
}
|
||||
|
||||
public function usesDnsRecordVerification(): bool
|
||||
{
|
||||
return $this->verification_method === self::METHOD_DNS_RECORD;
|
||||
}
|
||||
|
||||
public function getMailboxCountAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->count();
|
||||
}
|
||||
|
||||
public function getFreeMailboxesLimitAttribute($value): int
|
||||
{
|
||||
$base = $value ?? self::FREE_MAILBOXES_LIMIT;
|
||||
|
||||
// Check if user has hosting accounts with higher email quotas
|
||||
$hostingAllowance = $this->getHostingEmailAllowance();
|
||||
|
||||
return max($base, $hostingAllowance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the email allowance from the user's hosting accounts for this domain.
|
||||
*/
|
||||
public function getHostingEmailAllowance(): int
|
||||
{
|
||||
$hostingAccount = HostingAccount::query()
|
||||
->where('user_id', $this->user_id)
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->whereHas('sites', fn ($q) => $q->where('domain', $this->domain))
|
||||
->with('product')
|
||||
->first();
|
||||
|
||||
if (! $hostingAccount) {
|
||||
// Also check primary_domain
|
||||
$hostingAccount = HostingAccount::query()
|
||||
->where('user_id', $this->user_id)
|
||||
->where('status', HostingAccount::STATUS_ACTIVE)
|
||||
->where('primary_domain', $this->domain)
|
||||
->with('product')
|
||||
->first();
|
||||
}
|
||||
|
||||
if (! $hostingAccount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $hostingAccount->freeEmailAllowance() ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-record DNS verification results.
|
||||
*/
|
||||
public function getDnsRecordStatuses(): array
|
||||
{
|
||||
return $this->dns_verification_status ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an authentication record (spf|dkim|dmarc) should display as
|
||||
* verified. The per-record dns_verification_status map is only populated by
|
||||
* the DNS-record verification path; nameserver-managed / Ladill-owned domains
|
||||
* (we publish their DNS) leave it empty even when fully configured. So an
|
||||
* active domain necessarily has SPF + DKIM in place (required to activate),
|
||||
* and DMARC is published for nameserver-managed domains.
|
||||
*/
|
||||
public function authVerified(string $key): bool
|
||||
{
|
||||
if (($this->getDnsRecordStatuses()[$key]['status'] ?? null) === 'verified') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return match ($key) {
|
||||
'spf', 'dkim' => $this->isActive(),
|
||||
'dmarc' => $this->isActive() && $this->usesNameserverVerification(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all required DNS records are verified.
|
||||
*/
|
||||
public function allDnsRecordsVerified(): bool
|
||||
{
|
||||
$statuses = $this->getDnsRecordStatuses();
|
||||
|
||||
if (empty($statuses)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All required records must be verified
|
||||
foreach ($statuses as $record) {
|
||||
if (($record['required'] ?? true) && ($record['status'] ?? 'pending') !== 'verified') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count verified and total required DNS records.
|
||||
*/
|
||||
public function dnsVerificationProgress(): array
|
||||
{
|
||||
$statuses = $this->getDnsRecordStatuses();
|
||||
$required = collect($statuses)->filter(fn ($r) => $r['required'] ?? true);
|
||||
|
||||
return [
|
||||
'total' => $required->count(),
|
||||
'verified' => $required->where('status', 'verified')->count(),
|
||||
'pending' => $required->where('status', '!=', 'verified')->count(),
|
||||
];
|
||||
}
|
||||
|
||||
public function getFreeMailboxesUsedAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->where('is_paid', false)->count();
|
||||
}
|
||||
|
||||
public function getPaidMailboxesUsedAttribute(): int
|
||||
{
|
||||
return $this->mailboxes()->where('is_paid', true)->count();
|
||||
}
|
||||
|
||||
public function getFreeMailboxesRemainingAttribute(): int
|
||||
{
|
||||
return max(0, $this->free_mailboxes_limit - $this->free_mailboxes_used);
|
||||
}
|
||||
|
||||
public function canCreateFreeMailbox(): bool
|
||||
{
|
||||
return $this->free_mailboxes_remaining > 0;
|
||||
}
|
||||
|
||||
public function requiresPaymentForNextMailbox(): bool
|
||||
{
|
||||
return !$this->canCreateFreeMailbox();
|
||||
}
|
||||
|
||||
public function monthlySubscriptionPrice(): float
|
||||
{
|
||||
return self::SUBSCRIPTION_MONTHLY_PRICE_CEDIS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class EmailServer extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const ROLE_PRIMARY = 'primary';
|
||||
|
||||
public const ROLE_EXTENSION = 'extension';
|
||||
|
||||
public const PROVIDER_LOCAL = 'local';
|
||||
|
||||
public const PROVIDER_CONTABO = 'contabo';
|
||||
|
||||
public const PROVIDER_MANUAL = 'manual';
|
||||
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
|
||||
public const STATUS_FULL = 'full';
|
||||
|
||||
public const STATUS_DECOMMISSIONED = 'decommissioned';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'hostname',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'mail_host',
|
||||
'role',
|
||||
'primary_email_server_id',
|
||||
'pool_vmail_root',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'region',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'max_domains',
|
||||
'max_mailboxes',
|
||||
'current_domains',
|
||||
'current_mailboxes',
|
||||
'allocated_storage_mb',
|
||||
'used_storage_mb',
|
||||
'last_usage_sync_at',
|
||||
'db_host',
|
||||
'db_port',
|
||||
'db_database',
|
||||
'db_username',
|
||||
'db_password',
|
||||
'ssh_host',
|
||||
'ssh_user',
|
||||
'ssh_port',
|
||||
'ssh_private_key',
|
||||
'status',
|
||||
'is_default',
|
||||
'last_health_check_at',
|
||||
'health_status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'health_status' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'is_default' => 'boolean',
|
||||
'db_port' => 'integer',
|
||||
'ssh_port' => 'integer',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'db_password',
|
||||
'ssh_private_key',
|
||||
];
|
||||
|
||||
public function primary(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'primary_email_server_id');
|
||||
}
|
||||
|
||||
public function extensions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'primary_email_server_id');
|
||||
}
|
||||
|
||||
public function emailDomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(Domain::class);
|
||||
}
|
||||
|
||||
public function capacityAlerts(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailServerCapacityAlert::class);
|
||||
}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isExtension(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_EXTENSION;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function poolRoot(): self
|
||||
{
|
||||
return $this->isExtension() && $this->primary
|
||||
? $this->primary
|
||||
: $this;
|
||||
}
|
||||
|
||||
public function mailDatabaseServer(): self
|
||||
{
|
||||
return $this->poolRoot();
|
||||
}
|
||||
|
||||
public function scopePrimaries(Builder $query): Builder
|
||||
{
|
||||
return $query->where('role', self::ROLE_PRIMARY);
|
||||
}
|
||||
|
||||
public function scopeAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Per-account email preferences (defaults applied to new mailboxes). */
|
||||
class EmailSetting extends Model
|
||||
{
|
||||
protected $fillable = ['user_id', 'default_quota_mb', 'notify_email', 'product_updates'];
|
||||
|
||||
protected $casts = ['product_updates' => 'boolean'];
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Membership linking a user to an account (owner) they manage email for. */
|
||||
class EmailTeamMember extends Model
|
||||
{
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
public const STATUS_INVITED = 'invited';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
|
||||
|
||||
protected $casts = ['accepted_at' => 'datetime'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'account_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link any pending invites for this email to the user (called on SSO login),
|
||||
* so invited teammates gain access the first time they sign in.
|
||||
*/
|
||||
public static function linkPendingInvitesFor(User $user): void
|
||||
{
|
||||
static::query()
|
||||
->whereNull('user_id')
|
||||
->where('status', self::STATUS_INVITED)
|
||||
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
|
||||
->update([
|
||||
'user_id' => $user->id,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'accepted_at' => now(),
|
||||
'token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostedDatabase extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id', 'hosted_site_id', 'name', 'username',
|
||||
'password_encrypted', 'type', 'size_bytes', 'status',
|
||||
];
|
||||
|
||||
protected $casts = ['size_bytes' => 'integer'];
|
||||
protected $hidden = ['password_encrypted'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostedSite::class, 'hosted_site_id');
|
||||
}
|
||||
|
||||
public function getHostAttribute(): string
|
||||
{
|
||||
return 'localhost';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostedSite extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'domain_id',
|
||||
'domain',
|
||||
'document_root',
|
||||
'type',
|
||||
'status',
|
||||
'php_version',
|
||||
'ssl_enabled',
|
||||
'ssl_status',
|
||||
'ssl_error',
|
||||
'ssl_expires_at',
|
||||
'ssl_provisioned_at',
|
||||
'installed_app',
|
||||
'installed_app_version',
|
||||
'app_config',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'ssl_enabled' => 'boolean',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'app_config' => 'array',
|
||||
];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedDatabase::class);
|
||||
}
|
||||
|
||||
public function appInstallation(): HasOne
|
||||
{
|
||||
return $this->hasOne(AppInstallation::class)->where('status', 'active')->latestOfMany();
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingAccount extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_TERMINATED = 'terminated';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const RESOURCE_STATUS_ACTIVE = 'active';
|
||||
public const RESOURCE_STATUS_THROTTLED = 'throttled';
|
||||
public const RESOURCE_STATUS_SUSPENDED = 'suspended';
|
||||
|
||||
public const GRACE_PERIOD_MONTHS = 3;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_product_id',
|
||||
'hosting_node_id',
|
||||
'username',
|
||||
'primary_domain',
|
||||
'type',
|
||||
'status',
|
||||
'provider_account_id',
|
||||
'home_directory',
|
||||
'allocated_disk_gb',
|
||||
'disk_used_bytes',
|
||||
'inode_count',
|
||||
'cpu_usage_percent',
|
||||
'memory_used_mb',
|
||||
'process_count',
|
||||
'io_usage_mb',
|
||||
'last_usage_sync_at',
|
||||
'bandwidth_used_bytes',
|
||||
'bandwidth_reset_at',
|
||||
'php_version',
|
||||
'cpu_limit_percent',
|
||||
'memory_limit_mb',
|
||||
'process_limit',
|
||||
'io_limit_mb',
|
||||
'inode_limit',
|
||||
'resource_status',
|
||||
'uploads_restricted',
|
||||
'is_flagged',
|
||||
'warning_count',
|
||||
'cpu_breach_streak',
|
||||
'memory_breach_streak',
|
||||
'process_breach_streak',
|
||||
'io_breach_streak',
|
||||
'inode_breach_streak',
|
||||
'last_warning_at',
|
||||
'last_resource_breach_at',
|
||||
'throttled_at',
|
||||
'features_enabled',
|
||||
'resource_limits',
|
||||
'suspension_reason',
|
||||
'suspended_at',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features_enabled' => 'array',
|
||||
'resource_limits' => 'array',
|
||||
'metadata' => 'array',
|
||||
'allocated_disk_gb' => 'integer',
|
||||
'inode_count' => 'integer',
|
||||
'cpu_usage_percent' => 'decimal:2',
|
||||
'memory_used_mb' => 'integer',
|
||||
'process_count' => 'integer',
|
||||
'io_usage_mb' => 'decimal:2',
|
||||
'cpu_limit_percent' => 'integer',
|
||||
'memory_limit_mb' => 'integer',
|
||||
'process_limit' => 'integer',
|
||||
'io_limit_mb' => 'integer',
|
||||
'inode_limit' => 'integer',
|
||||
'uploads_restricted' => 'boolean',
|
||||
'is_flagged' => 'boolean',
|
||||
'warning_count' => 'integer',
|
||||
'cpu_breach_streak' => 'integer',
|
||||
'memory_breach_streak' => 'integer',
|
||||
'process_breach_streak' => 'integer',
|
||||
'io_breach_streak' => 'integer',
|
||||
'inode_breach_streak' => 'integer',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'last_warning_at' => 'datetime',
|
||||
'last_resource_breach_at' => 'datetime',
|
||||
'throttled_at' => 'datetime',
|
||||
'bandwidth_reset_at' => 'datetime',
|
||||
'suspended_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'hosting_product_id');
|
||||
}
|
||||
|
||||
public function node(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class, 'hosting_node_id');
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedSite::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostedDatabase::class);
|
||||
}
|
||||
|
||||
public function teamMembers(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccountMember::class);
|
||||
}
|
||||
|
||||
public function developers(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'hosting_account_members')
|
||||
->withPivot(['role', 'invited_by_user_id', 'invited_at'])
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function alerts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccountAlert::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function billingInvoices(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingBillingInvoice::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function planChanges(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingPlanChange::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function latestOrder(): HasOne
|
||||
{
|
||||
return $this->hasOne(CustomerHostingOrder::class, 'hosting_account_id')->latestOfMany();
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('status', 'active');
|
||||
}
|
||||
|
||||
public function isThrottled(): bool
|
||||
{
|
||||
return $this->resource_status === self::RESOURCE_STATUS_THROTTLED;
|
||||
}
|
||||
|
||||
public function uploadsAreRestricted(): bool
|
||||
{
|
||||
return (bool) $this->uploads_restricted || (
|
||||
$this->inode_limit > 0 && $this->inode_count >= $this->inode_limit
|
||||
);
|
||||
}
|
||||
|
||||
public function assignedDurationMonths(): ?int
|
||||
{
|
||||
$months = data_get($this->metadata, 'assigned_duration_months');
|
||||
|
||||
return is_numeric($months) && (int) $months > 0 ? (int) $months : null;
|
||||
}
|
||||
|
||||
public function canBeRenewed(): bool
|
||||
{
|
||||
return $this->assignedDurationMonths() !== null;
|
||||
}
|
||||
|
||||
public function requestedDiskGb(): int
|
||||
{
|
||||
if ($this->allocated_disk_gb > 0) {
|
||||
return (int) $this->allocated_disk_gb;
|
||||
}
|
||||
|
||||
return (int) ($this->product?->disk_gb
|
||||
?? data_get($this->resource_limits, 'disk_gb')
|
||||
?? 0);
|
||||
}
|
||||
|
||||
public function diskLimitBytes(): int
|
||||
{
|
||||
return max($this->requestedDiskGb(), 0) * 1073741824;
|
||||
}
|
||||
|
||||
public function diskUsagePercent(): float
|
||||
{
|
||||
$limit = $this->diskLimitBytes();
|
||||
|
||||
if ($limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->disk_used_bytes / $limit) * 100, 2);
|
||||
}
|
||||
|
||||
public function inodeUsagePercent(): float
|
||||
{
|
||||
if (! $this->inode_limit || $this->inode_limit <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->inode_count / $this->inode_limit) * 100, 2);
|
||||
}
|
||||
|
||||
public function includedEmailAccounts(): ?int
|
||||
{
|
||||
$limit = $this->product?->max_email_accounts;
|
||||
|
||||
if ($limit === null) {
|
||||
$limit = data_get($this->resource_limits, 'max_email_accounts');
|
||||
}
|
||||
|
||||
return $limit === null ? null : (int) $limit;
|
||||
}
|
||||
|
||||
public function freeEmailAllowance(): ?int
|
||||
{
|
||||
$included = $this->includedEmailAccounts();
|
||||
|
||||
if ($included === null || $included < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return max($included, 0);
|
||||
}
|
||||
|
||||
public function paidMailboxCount(): int
|
||||
{
|
||||
return $this->relationLoaded('mailboxes')
|
||||
? $this->mailboxes->where('is_paid', true)->count()
|
||||
: $this->mailboxes()->where('is_paid', true)->count();
|
||||
}
|
||||
|
||||
public function renew(?int $durationMonths = null, array $metadata = []): void
|
||||
{
|
||||
$months = $durationMonths ?? $this->assignedDurationMonths();
|
||||
|
||||
if (! is_int($months) || $months < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseDate = $this->expires_at instanceof CarbonInterface && $this->expires_at->isFuture()
|
||||
? $this->expires_at->copy()
|
||||
: now();
|
||||
|
||||
$currentMetadata = (array) ($this->metadata ?? []);
|
||||
|
||||
$mergedMetadata = array_merge($currentMetadata, $metadata, [
|
||||
'assigned_duration_months' => $months,
|
||||
]);
|
||||
unset($mergedMetadata['expiry_alerts_sent']);
|
||||
|
||||
$this->update([
|
||||
'expires_at' => $baseDate->addMonthsNoOverflow($months),
|
||||
'metadata' => $mergedMetadata,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at instanceof CarbonInterface && $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
public function isInGracePeriod(): bool
|
||||
{
|
||||
if (! $this->isExpired()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->gracePeriodEndsAt()->isFuture();
|
||||
}
|
||||
|
||||
public function gracePeriodEndsAt(): ?CarbonInterface
|
||||
{
|
||||
if (! $this->expires_at instanceof CarbonInterface) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->expires_at->copy()->addMonths(self::GRACE_PERIOD_MONTHS);
|
||||
}
|
||||
|
||||
public function isPastGracePeriod(): bool
|
||||
{
|
||||
$endsAt = $this->gracePeriodEndsAt();
|
||||
|
||||
return $endsAt !== null && $endsAt->isPast();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingAccountAlert extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_WARNING = 'warning';
|
||||
public const TYPE_LIMIT_EXCEEDED = 'limit_exceeded';
|
||||
public const TYPE_THROTTLED = 'throttled';
|
||||
public const TYPE_SUSPENDED = 'suspended';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'alert_type',
|
||||
'severity',
|
||||
'message',
|
||||
'resource_usage',
|
||||
'is_resolved',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'resource_usage' => 'array',
|
||||
'is_resolved' => 'boolean',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function scopeUnresolved($query)
|
||||
{
|
||||
return $query->where('is_resolved', false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingAccountMember extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const ROLE_DEVELOPER = 'developer';
|
||||
|
||||
private const SSH_KEY_PATTERN = '/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\\.com|sk-ecdsa-sha2-nistp256@openssh\\.com) [A-Za-z0-9+\\/]+={0,3}(?: .+)?$/';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_account_id',
|
||||
'user_id',
|
||||
'invited_by_user_id',
|
||||
'role',
|
||||
'invited_at',
|
||||
'ssh_public_key',
|
||||
'ssh_key_installed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'invited_at' => 'datetime',
|
||||
'ssh_key_installed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function invitedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'invited_by_user_id');
|
||||
}
|
||||
|
||||
public function sshKeyMarker(): string
|
||||
{
|
||||
return 'ladill-team-member-'.$this->id;
|
||||
}
|
||||
|
||||
public function hasSshAccessKey(): bool
|
||||
{
|
||||
return filled($this->ssh_public_key);
|
||||
}
|
||||
|
||||
public static function normalizeSshPublicKey(string $value): string
|
||||
{
|
||||
return trim(preg_replace('/\s+/', ' ', trim($value)) ?? '');
|
||||
}
|
||||
|
||||
public static function looksLikeSshPublicKey(?string $value): bool
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$normalized = static::normalizeSshPublicKey($value);
|
||||
|
||||
return $normalized !== '' && preg_match(self::SSH_KEY_PATTERN, $normalized) === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class HostingBillingInvoice extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_PLAN_CHANGE = 'plan_change';
|
||||
public const TYPE_EMAIL_ADDON = 'email_addon';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_CREDITED = 'credited';
|
||||
public const STATUS_WAIVED = 'waived';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_account_id',
|
||||
'hosting_plan_change_id',
|
||||
'invoice_type',
|
||||
'status',
|
||||
'currency',
|
||||
'subtotal_amount',
|
||||
'credit_amount',
|
||||
'total_amount',
|
||||
'description',
|
||||
'provider_reference',
|
||||
'paid_at',
|
||||
'line_items',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal_amount' => 'decimal:2',
|
||||
'credit_amount' => 'decimal:2',
|
||||
'total_amount' => 'decimal:2',
|
||||
'paid_at' => 'datetime',
|
||||
'line_items' => 'array',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function planChange(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlanChange::class, 'hosting_plan_change_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingNode extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'hostname',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'type',
|
||||
'role',
|
||||
'primary_hosting_node_id',
|
||||
'segment',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'region',
|
||||
'datacenter',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'oversell_ratio',
|
||||
'allocated_disk_gb',
|
||||
'used_disk_gb',
|
||||
'bandwidth_tb',
|
||||
'max_accounts',
|
||||
'current_accounts',
|
||||
'current_load_percent',
|
||||
'status',
|
||||
'features',
|
||||
'installed_software',
|
||||
'ssh_port',
|
||||
'ssh_private_key',
|
||||
'control_panel',
|
||||
'last_health_check_at',
|
||||
'health_status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features' => 'array',
|
||||
'installed_software' => 'array',
|
||||
'health_status' => 'array',
|
||||
'last_health_check_at' => 'datetime',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'disk_gb' => 'integer',
|
||||
'oversell_ratio' => 'decimal:2',
|
||||
'allocated_disk_gb' => 'integer',
|
||||
'used_disk_gb' => 'integer',
|
||||
'bandwidth_tb' => 'integer',
|
||||
'max_accounts' => 'integer',
|
||||
'current_accounts' => 'integer',
|
||||
'current_load_percent' => 'decimal:2',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'ssh_private_key',
|
||||
];
|
||||
|
||||
public const ROLE_PRIMARY = 'primary';
|
||||
|
||||
public const ROLE_EXTENSION = 'extension';
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
|
||||
public const SEGMENT_GENERAL = 'general';
|
||||
public const SEGMENT_STARTER = 'starter';
|
||||
public const SEGMENT_WORDPRESS = 'wordpress';
|
||||
public const SEGMENT_HIGH_RESOURCE = 'high_resource';
|
||||
|
||||
public const PROVIDER_CONTABO = 'contabo';
|
||||
public const PROVIDER_LOCAL = 'local';
|
||||
public const PROVIDER_MANUAL = 'manual';
|
||||
public const PROVIDER_LEGACY_RC = 'legacy_rc';
|
||||
|
||||
public const STATUS_PROVISIONING = 'provisioning';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_MAINTENANCE = 'maintenance';
|
||||
public const STATUS_FULL = 'full';
|
||||
public const STATUS_DECOMMISSIONED = 'decommissioned';
|
||||
|
||||
public function primary(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'primary_hosting_node_id');
|
||||
}
|
||||
|
||||
public function extensions(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'primary_hosting_node_id');
|
||||
}
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function isPrimary(): bool
|
||||
{
|
||||
return ($this->role ?? self::ROLE_PRIMARY) === self::ROLE_PRIMARY;
|
||||
}
|
||||
|
||||
public function isExtension(): bool
|
||||
{
|
||||
return $this->role === self::ROLE_EXTENSION;
|
||||
}
|
||||
|
||||
public function poolRoot(): self
|
||||
{
|
||||
return $this->isExtension() && $this->primary
|
||||
? $this->primary
|
||||
: $this;
|
||||
}
|
||||
|
||||
public function isAvailable(): bool
|
||||
{
|
||||
if ($this->status !== self::STATUS_ACTIVE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function supportsSegment(?string $segment): bool
|
||||
{
|
||||
if ($segment === null || $segment === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($this->segment, [$segment, self::SEGMENT_GENERAL], true);
|
||||
}
|
||||
|
||||
public function logicalCapacityGb(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ratio = (float) ($this->oversell_ratio ?: 1);
|
||||
|
||||
return round($this->disk_gb * max($ratio, 1), 2);
|
||||
}
|
||||
|
||||
public function logicalCapacityPercent(): float
|
||||
{
|
||||
$logicalCapacity = $this->logicalCapacityGb();
|
||||
|
||||
if ($logicalCapacity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->allocated_disk_gb / $logicalCapacity) * 100, 2);
|
||||
}
|
||||
|
||||
public function physicalDiskPercent(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->used_disk_gb / $this->disk_gb) * 100, 2);
|
||||
}
|
||||
|
||||
public function accountCapacityPercent(): float
|
||||
{
|
||||
if (! $this->max_accounts || $this->max_accounts <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return round(($this->current_accounts / $this->max_accounts) * 100, 2);
|
||||
}
|
||||
|
||||
public function hasLogicalCapacityFor(int $requestedDiskGb = 0): bool
|
||||
{
|
||||
if ($requestedDiskGb <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ($this->used_disk_gb + $requestedDiskGb) <= $this->disk_gb;
|
||||
}
|
||||
|
||||
public function availableLogicalDiskGb(): float
|
||||
{
|
||||
if (! $this->disk_gb || $this->disk_gb <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max($this->disk_gb - $this->used_disk_gb, 0);
|
||||
}
|
||||
|
||||
public function canProvision(int $requestedDiskGb = 0, ?string $segment = null): bool
|
||||
{
|
||||
if (! $this->isAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->supportsSegment($segment)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasLogicalCapacityFor($requestedDiskGb);
|
||||
}
|
||||
|
||||
public function incrementAccountCount(): void
|
||||
{
|
||||
$this->increment('current_accounts');
|
||||
|
||||
if ($this->max_accounts && $this->current_accounts >= $this->max_accounts) {
|
||||
$this->update(['status' => self::STATUS_FULL]);
|
||||
}
|
||||
}
|
||||
|
||||
public function decrementAccountCount(): void
|
||||
{
|
||||
$this->decrement('current_accounts');
|
||||
|
||||
if ($this->status === self::STATUS_FULL && $this->current_accounts < $this->max_accounts) {
|
||||
$this->update(['status' => self::STATUS_ACTIVE]);
|
||||
}
|
||||
}
|
||||
|
||||
public function scopeAvailable($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_ACTIVE)
|
||||
->whereRaw('current_accounts < COALESCE(max_accounts, 999999)');
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class HostingOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_SINGLE_DOMAIN = 'single_domain';
|
||||
public const TYPE_MULTI_DOMAIN = 'multi_domain';
|
||||
public const TYPE_RESELLER = 'reseller';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'domain_id',
|
||||
'domain_name',
|
||||
'rc_order_id',
|
||||
'rc_customer_id',
|
||||
'plan_name',
|
||||
'hosting_type',
|
||||
'status',
|
||||
'ip_address',
|
||||
'cpanel_url',
|
||||
'cpanel_username',
|
||||
'bandwidth_mb',
|
||||
'disk_space_mb',
|
||||
'expires_at',
|
||||
'provisioned_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_EXPIRED
|
||||
|| ($this->expires_at && $this->expires_at->isPast());
|
||||
}
|
||||
|
||||
public function getDomainNameAttribute($value): string
|
||||
{
|
||||
return $value ?: $this->metaValue([
|
||||
'domain_name',
|
||||
'domainname',
|
||||
'domain-name',
|
||||
'domain',
|
||||
'hostname',
|
||||
'description',
|
||||
], '');
|
||||
}
|
||||
|
||||
public function getPlanNameAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'plan_name',
|
||||
'planname',
|
||||
'productcategory',
|
||||
'productkey',
|
||||
'description',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getPlanIdAttribute(): ?string
|
||||
{
|
||||
$resolved = $this->metaValue([
|
||||
'plan_id',
|
||||
'planid',
|
||||
'plan-id',
|
||||
'productkey',
|
||||
'plan.name',
|
||||
'entitytype.entitytypekey',
|
||||
]);
|
||||
|
||||
return $resolved !== null && $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getRcOrderIdAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'rc_order_id',
|
||||
'order_id',
|
||||
'orderid',
|
||||
'order-id',
|
||||
'entityid',
|
||||
'entity-id',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getRcCustomerIdAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'rc_customer_id',
|
||||
'customer_id',
|
||||
'customerid',
|
||||
'customer-id',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getIpAddressAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'ip_address',
|
||||
'ipaddress',
|
||||
'ip',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getCpanelUrlAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'cpanel_url',
|
||||
'cpanelurl',
|
||||
'controlpanelurl',
|
||||
'access_url',
|
||||
'url',
|
||||
]);
|
||||
|
||||
if ($this->looksLikeTemporaryHostingUrl($resolved)) {
|
||||
$resolved = null;
|
||||
}
|
||||
|
||||
if (! $resolved && $preferredDomain = $this->preferredCpanelDomain()) {
|
||||
return 'https://'.$preferredDomain.'/cpanel';
|
||||
}
|
||||
|
||||
if (! $resolved && $this->ip_address) {
|
||||
return 'https://'.$this->ip_address.':2083';
|
||||
}
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
public function getCpanelUsernameAttribute($value): ?string
|
||||
{
|
||||
$resolved = $value ?: $this->metaValue([
|
||||
'cpanel_username',
|
||||
'cpanelusername',
|
||||
'access_username',
|
||||
'username',
|
||||
'siteadminusername',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? $resolved : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getNameserversAttribute($value): array
|
||||
{
|
||||
$resolved = [];
|
||||
|
||||
if (is_array($value)) {
|
||||
$resolved = $value;
|
||||
} else {
|
||||
$candidate = $this->metaValue([
|
||||
'nameservers',
|
||||
'nameserver',
|
||||
'name_servers',
|
||||
'ns',
|
||||
'dns.nameservers',
|
||||
'server.nameservers',
|
||||
]);
|
||||
|
||||
if (is_array($candidate)) {
|
||||
$resolved = $candidate;
|
||||
} else {
|
||||
$resolved = array_filter([
|
||||
$this->metaValue(['ns1', 'nameserver1', 'name_server_1', 'server.ns1']),
|
||||
$this->metaValue(['ns2', 'nameserver2', 'name_server_2', 'server.ns2']),
|
||||
$this->metaValue(['ns3', 'nameserver3', 'name_server_3', 'server.ns3']),
|
||||
$this->metaValue(['ns4', 'nameserver4', 'name_server_4', 'server.ns4']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return collect($resolved)
|
||||
->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B.")))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function getWebsiteUrlAttribute(): ?string
|
||||
{
|
||||
$domain = strtolower(trim((string) $this->domain_name));
|
||||
|
||||
if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'https://'.$domain;
|
||||
}
|
||||
|
||||
public function getDirectUrlAttribute(): ?string
|
||||
{
|
||||
$resolved = $this->metaValue([
|
||||
'tempurl',
|
||||
'temp_url',
|
||||
'temporary_url',
|
||||
'server.tempurl',
|
||||
'server.temporary_url',
|
||||
]);
|
||||
|
||||
return $resolved !== '' ? (string) $resolved : null;
|
||||
}
|
||||
|
||||
public function getDiskSpaceMbAttribute($value): ?int
|
||||
{
|
||||
$resolved = $value ?? $this->metaValue([
|
||||
'disk_space_mb',
|
||||
'diskspace',
|
||||
'disk_space',
|
||||
]);
|
||||
|
||||
return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null;
|
||||
}
|
||||
|
||||
public function getBandwidthMbAttribute($value): ?int
|
||||
{
|
||||
$resolved = $value ?? $this->metaValue([
|
||||
'bandwidth_mb',
|
||||
'bandwidth',
|
||||
]);
|
||||
|
||||
return is_numeric($resolved) && (int) $resolved >= 0 ? (int) $resolved : null;
|
||||
}
|
||||
|
||||
public function getStatusAttribute($value): string
|
||||
{
|
||||
$candidate = $value;
|
||||
|
||||
if ($candidate === null || $candidate === '' || $candidate === self::STATUS_PENDING) {
|
||||
$candidate = $this->metaValue([
|
||||
'status',
|
||||
'currentstatus',
|
||||
'orderstatus',
|
||||
], $candidate ?: self::STATUS_PENDING);
|
||||
}
|
||||
|
||||
return $this->normalizeStatus((string) $candidate);
|
||||
}
|
||||
|
||||
public function getProvisionedAtAttribute($value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
$timestamp = $this->metaValue([
|
||||
'provisioned_at',
|
||||
'creation_time',
|
||||
'creationtime',
|
||||
'creation-date',
|
||||
]);
|
||||
|
||||
if ($timestamp === null || $timestamp === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return is_numeric($timestamp)
|
||||
? Carbon::createFromTimestamp((int) $timestamp)
|
||||
: Carbon::parse((string) $timestamp);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function getExpiresAtAttribute($value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value) {
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
$timestamp = $this->metaValue([
|
||||
'expires_at',
|
||||
'end_time',
|
||||
'endtime',
|
||||
'expirytime',
|
||||
'expiry-date',
|
||||
]);
|
||||
|
||||
if ($timestamp === null || $timestamp === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return is_numeric($timestamp)
|
||||
? Carbon::createFromTimestamp((int) $timestamp)
|
||||
: Carbon::parse((string) $timestamp);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function metaValue(array $keys, mixed $default = null): mixed
|
||||
{
|
||||
$meta = $this->meta ?? [];
|
||||
$sources = [];
|
||||
|
||||
if (is_array($meta)) {
|
||||
$sources[] = $meta;
|
||||
|
||||
if (is_array($meta['raw'] ?? null)) {
|
||||
$sources[] = $meta['raw'];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
foreach ($keys as $key) {
|
||||
$nested = Arr::get($source, $key);
|
||||
if ($nested !== null && $nested !== '') {
|
||||
return $nested;
|
||||
}
|
||||
|
||||
if (array_key_exists($key, $source) && $source[$key] !== null && $source[$key] !== '') {
|
||||
return $source[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
private function normalizeStatus(string $status): string
|
||||
{
|
||||
return match (strtolower(trim($status))) {
|
||||
'active', 'active (paid)' => self::STATUS_ACTIVE,
|
||||
'suspended', 'inactive' => self::STATUS_SUSPENDED,
|
||||
'deleted', 'cancelled' => self::STATUS_CANCELLED,
|
||||
'expired' => self::STATUS_EXPIRED,
|
||||
'failed' => self::STATUS_FAILED,
|
||||
default => self::STATUS_PENDING,
|
||||
};
|
||||
}
|
||||
|
||||
private function looksLikeTemporaryHostingUrl(?string $value): bool
|
||||
{
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
|
||||
if ($normalized === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return str_contains($normalized, 'tempwebhost.net')
|
||||
|| str_contains($normalized, '/~');
|
||||
}
|
||||
|
||||
private function preferredCpanelDomain(): ?string
|
||||
{
|
||||
$domain = strtolower(trim((string) $this->domain_name));
|
||||
|
||||
if ($domain === '' || filter_var($domain, FILTER_VALIDATE_IP)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('domain') && $this->domain) {
|
||||
return $this->domainIsActive($this->domain) ? $domain : null;
|
||||
}
|
||||
|
||||
if ($this->domain_id) {
|
||||
$linkedDomain = $this->domain()->first();
|
||||
|
||||
if ($linkedDomain) {
|
||||
return $this->domainIsActive($linkedDomain) ? $domain : null;
|
||||
}
|
||||
}
|
||||
|
||||
$matchingDomain = Domain::query()
|
||||
->where('host', $domain)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($matchingDomain) {
|
||||
return $this->domainIsActive($matchingDomain) ? $domain : null;
|
||||
}
|
||||
|
||||
return $domain;
|
||||
}
|
||||
|
||||
private function domainIsActive(Domain $domain): bool
|
||||
{
|
||||
return (string) $domain->status === 'verified'
|
||||
&& (string) $domain->onboarding_state === Domain::STATE_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingPlan extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'type',
|
||||
'price_monthly',
|
||||
'price_yearly',
|
||||
'currency',
|
||||
'disk_gb',
|
||||
'bandwidth_gb',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'max_domains',
|
||||
'max_databases',
|
||||
'max_email_accounts',
|
||||
'max_ftp_accounts',
|
||||
'ssl_included',
|
||||
'backups_included',
|
||||
'backup_retention_days',
|
||||
'features',
|
||||
'php_versions',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'sort_order',
|
||||
'contabo_product_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_monthly' => 'decimal:2',
|
||||
'price_yearly' => 'decimal:2',
|
||||
'disk_gb' => 'integer',
|
||||
'bandwidth_gb' => 'integer',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'max_domains' => 'integer',
|
||||
'max_databases' => 'integer',
|
||||
'max_email_accounts' => 'integer',
|
||||
'max_ftp_accounts' => 'integer',
|
||||
'ssl_included' => 'boolean',
|
||||
'backups_included' => 'boolean',
|
||||
'backup_retention_days' => 'integer',
|
||||
'features' => 'array',
|
||||
'php_versions' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public const TYPE_SHARED = 'shared';
|
||||
public const TYPE_WORDPRESS = 'wordpress';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL = 'email';
|
||||
|
||||
public function accounts(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function vpsInstances(): HasMany
|
||||
{
|
||||
return $this->hasMany(VpsInstance::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeFeatured($query)
|
||||
{
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('price_monthly');
|
||||
}
|
||||
|
||||
public function getYearlyDiscount(): ?float
|
||||
{
|
||||
if (!$this->price_yearly || !$this->price_monthly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$monthlyTotal = $this->price_monthly * 12;
|
||||
return round((($monthlyTotal - $this->price_yearly) / $monthlyTotal) * 100, 1);
|
||||
}
|
||||
|
||||
public function formatDiskSize(): string
|
||||
{
|
||||
if ($this->disk_gb >= 1000) {
|
||||
return round($this->disk_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->disk_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatRam(): string
|
||||
{
|
||||
if ($this->ram_mb >= 1024) {
|
||||
return round($this->ram_mb / 1024, 1) . ' GB';
|
||||
}
|
||||
return $this->ram_mb . ' MB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
class HostingPlanChange extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const DIRECTION_UPGRADE = 'upgrade';
|
||||
public const DIRECTION_DOWNGRADE = 'downgrade';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_account_id',
|
||||
'from_hosting_product_id',
|
||||
'to_hosting_product_id',
|
||||
'direction',
|
||||
'billing_cycle',
|
||||
'cycle_months',
|
||||
'remaining_days',
|
||||
'proration_ratio',
|
||||
'old_cycle_price',
|
||||
'new_cycle_price',
|
||||
'charge_amount',
|
||||
'credit_amount',
|
||||
'net_amount',
|
||||
'currency',
|
||||
'status',
|
||||
'applied_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cycle_months' => 'integer',
|
||||
'remaining_days' => 'integer',
|
||||
'proration_ratio' => 'decimal:4',
|
||||
'old_cycle_price' => 'decimal:2',
|
||||
'new_cycle_price' => 'decimal:2',
|
||||
'charge_amount' => 'decimal:2',
|
||||
'credit_amount' => 'decimal:2',
|
||||
'net_amount' => 'decimal:2',
|
||||
'applied_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class, 'hosting_account_id');
|
||||
}
|
||||
|
||||
public function fromProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'from_hosting_product_id');
|
||||
}
|
||||
|
||||
public function toProduct(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingProduct::class, 'to_hosting_product_id');
|
||||
}
|
||||
|
||||
public function invoice(): HasOne
|
||||
{
|
||||
return $this->hasOne(HostingBillingInvoice::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\Hosting\HostingPricingService;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class HostingProduct extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
public const CATEGORY_SHARED = 'shared';
|
||||
public const CATEGORY_VPS = 'vps';
|
||||
public const CATEGORY_DEDICATED = 'dedicated';
|
||||
public const CATEGORY_EMAIL = 'email';
|
||||
|
||||
public const TYPE_SINGLE_DOMAIN = 'single_domain';
|
||||
public const TYPE_MULTI_DOMAIN = 'multi_domain';
|
||||
public const TYPE_WORDPRESS = 'wordpress';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL_STANDALONE = 'email_standalone';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'category',
|
||||
'type',
|
||||
'price_monthly',
|
||||
'price_quarterly',
|
||||
'price_yearly',
|
||||
'price_biennial',
|
||||
'setup_fee',
|
||||
'currency',
|
||||
'disk_gb',
|
||||
'bandwidth_gb',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'max_domains',
|
||||
'max_databases',
|
||||
'max_email_accounts',
|
||||
'max_ftp_accounts',
|
||||
'ssl_included',
|
||||
'backups_included',
|
||||
'backup_retention_days',
|
||||
'features',
|
||||
'php_versions',
|
||||
'contabo_product_id',
|
||||
'contabo_region',
|
||||
'is_active',
|
||||
'is_featured',
|
||||
'is_visible',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $attributes = [
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price_monthly' => 'decimal:2',
|
||||
'price_quarterly' => 'decimal:2',
|
||||
'price_yearly' => 'decimal:2',
|
||||
'price_biennial' => 'decimal:2',
|
||||
'setup_fee' => 'decimal:2',
|
||||
'disk_gb' => 'integer',
|
||||
'bandwidth_gb' => 'integer',
|
||||
'cpu_cores' => 'integer',
|
||||
'ram_mb' => 'integer',
|
||||
'max_domains' => 'integer',
|
||||
'max_databases' => 'integer',
|
||||
'max_email_accounts' => 'integer',
|
||||
'max_ftp_accounts' => 'integer',
|
||||
'ssl_included' => 'boolean',
|
||||
'backups_included' => 'boolean',
|
||||
'backup_retention_days' => 'integer',
|
||||
'features' => 'array',
|
||||
'php_versions' => 'array',
|
||||
'is_active' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'is_visible' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(CustomerHostingOrder::class);
|
||||
}
|
||||
|
||||
public function scopeActive($query)
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
public function scopeVisible($query)
|
||||
{
|
||||
return $query->where('is_visible', true);
|
||||
}
|
||||
|
||||
public function scopeOfCategory($query, string $category)
|
||||
{
|
||||
return $query->where('category', $category);
|
||||
}
|
||||
|
||||
public function scopeOfType($query, string $type)
|
||||
{
|
||||
return $query->where('type', $type);
|
||||
}
|
||||
|
||||
public function scopeSharedHosting($query)
|
||||
{
|
||||
return $query->where('category', self::CATEGORY_SHARED);
|
||||
}
|
||||
|
||||
public function scopeServers($query)
|
||||
{
|
||||
return $query->whereIn('category', [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]);
|
||||
}
|
||||
|
||||
public function scopeOrdered($query)
|
||||
{
|
||||
return $query->orderBy('sort_order')->orderBy('price_monthly');
|
||||
}
|
||||
|
||||
public function isSharedHosting(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_SHARED;
|
||||
}
|
||||
|
||||
public function isVps(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_VPS;
|
||||
}
|
||||
|
||||
public function isDedicated(): bool
|
||||
{
|
||||
return $this->category === self::CATEGORY_DEDICATED;
|
||||
}
|
||||
|
||||
public function requiresContabo(): bool
|
||||
{
|
||||
return in_array($this->category, [self::CATEGORY_VPS, self::CATEGORY_DEDICATED]);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return (bool) $this->is_active;
|
||||
}
|
||||
|
||||
public function isVisible(): bool
|
||||
{
|
||||
return (bool) $this->is_visible;
|
||||
}
|
||||
|
||||
public function getPriceForCycle(string $cycle): ?float
|
||||
{
|
||||
// Use dynamic pricing for VPS/Dedicated
|
||||
if ($this->requiresContabo()) {
|
||||
// Contabo VPS does not offer quarterly billing
|
||||
if ($this->isVps() && $cycle === 'quarterly') {
|
||||
return null;
|
||||
}
|
||||
$pricing = $this->getDynamicPricing();
|
||||
return match ($cycle) {
|
||||
'monthly' => $pricing['monthly'],
|
||||
'quarterly' => $pricing['quarterly'],
|
||||
'semiannual' => $pricing['semiannual'],
|
||||
'yearly' => $pricing['yearly'],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return match ($cycle) {
|
||||
'monthly' => (float) $this->price_monthly,
|
||||
'quarterly' => (float) $this->price_quarterly,
|
||||
'semiannual' => null,
|
||||
'yearly' => (float) $this->price_yearly,
|
||||
'biennial' => (float) $this->price_biennial,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function getDynamicPricing(): array
|
||||
{
|
||||
$pricingService = app(HostingPricingService::class);
|
||||
return $pricingService->getDynamicPriceForProduct($this);
|
||||
}
|
||||
|
||||
public function getPricingBreakdown(): array
|
||||
{
|
||||
$pricingService = app(HostingPricingService::class);
|
||||
return $pricingService->getPricingBreakdown($this);
|
||||
}
|
||||
|
||||
public function getDisplayPriceMonthlyAttribute(): float
|
||||
{
|
||||
if ($this->requiresContabo()) {
|
||||
return $this->getDynamicPricing()['monthly'];
|
||||
}
|
||||
return (float) $this->price_monthly;
|
||||
}
|
||||
|
||||
public function getDisplayPriceYearlyAttribute(): float
|
||||
{
|
||||
if ($this->requiresContabo()) {
|
||||
return $this->getDynamicPricing()['yearly'];
|
||||
}
|
||||
return (float) $this->price_yearly;
|
||||
}
|
||||
|
||||
public function getDisplayCurrencyAttribute(): string
|
||||
{
|
||||
return 'GHS';
|
||||
}
|
||||
|
||||
public function getYearlyDiscount(): ?float
|
||||
{
|
||||
$monthlyPrice = $this->display_price_monthly;
|
||||
$yearlyPrice = $this->display_price_yearly;
|
||||
|
||||
if (!$yearlyPrice || !$monthlyPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$monthlyTotal = $monthlyPrice * 12;
|
||||
return round((($monthlyTotal - $yearlyPrice) / $monthlyTotal) * 100, 1);
|
||||
}
|
||||
|
||||
public function catalogSummary(): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
if ($this->disk_gb !== null) {
|
||||
$parts[] = $this->formatDiskSize();
|
||||
}
|
||||
|
||||
$domainLimit = $this->formatDomainLimit();
|
||||
if ($domainLimit !== null) {
|
||||
$parts[] = $domainLimit;
|
||||
}
|
||||
|
||||
return $parts !== [] ? implode(' · ', $parts) : null;
|
||||
}
|
||||
|
||||
public function formatDiskSize(): string
|
||||
{
|
||||
if (!$this->disk_gb) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
if ($this->disk_gb >= 1000) {
|
||||
return round($this->disk_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->disk_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatRam(): string
|
||||
{
|
||||
if (!$this->ram_mb) {
|
||||
return '-';
|
||||
}
|
||||
if ($this->ram_mb >= 1024) {
|
||||
return round($this->ram_mb / 1024, 1) . ' GB';
|
||||
}
|
||||
return $this->ram_mb . ' MB';
|
||||
}
|
||||
|
||||
public function formatBandwidth(): string
|
||||
{
|
||||
if (!$this->bandwidth_gb) {
|
||||
return 'Unlimited';
|
||||
}
|
||||
if ($this->bandwidth_gb >= 1000) {
|
||||
return round($this->bandwidth_gb / 1000, 1) . ' TB';
|
||||
}
|
||||
return $this->bandwidth_gb . ' GB';
|
||||
}
|
||||
|
||||
public function formatDomainLimit(): ?string
|
||||
{
|
||||
if ($this->max_domains === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->max_domains === -1) {
|
||||
return 'Unlimited sites';
|
||||
}
|
||||
|
||||
return $this->max_domains.' site'.($this->max_domains === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
public function preferredNodeSegment(): ?string
|
||||
{
|
||||
if (! $this->isSharedHosting()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($this->type) {
|
||||
self::TYPE_WORDPRESS => HostingNode::SEGMENT_WORDPRESS,
|
||||
self::TYPE_MULTI_DOMAIN => HostingNode::SEGMENT_HIGH_RESOURCE,
|
||||
self::TYPE_SINGLE_DOMAIN => HostingNode::SEGMENT_STARTER,
|
||||
default => HostingNode::SEGMENT_GENERAL,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/** Membership linking a user to an account (owner) they manage hosting for. */
|
||||
class HostingTeamMember extends Model
|
||||
{
|
||||
public const ROLE_ADMIN = 'admin';
|
||||
public const ROLE_MEMBER = 'member';
|
||||
|
||||
public const STATUS_INVITED = 'invited';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
protected $fillable = ['account_id', 'user_id', 'email', 'role', 'status', 'token', 'accepted_at'];
|
||||
|
||||
protected $casts = ['accepted_at' => 'datetime'];
|
||||
|
||||
public function account(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'account_id');
|
||||
}
|
||||
|
||||
public function member(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Link any pending invites for this email to the user (called on SSO login),
|
||||
* so invited teammates gain access the first time they sign in.
|
||||
*/
|
||||
public static function linkPendingInvitesFor(User $user): void
|
||||
{
|
||||
static::query()
|
||||
->whereNull('user_id')
|
||||
->where('status', self::STATUS_INVITED)
|
||||
->whereRaw('LOWER(email) = ?', [strtolower($user->email)])
|
||||
->update([
|
||||
'user_id' => $user->id,
|
||||
'status' => self::STATUS_ACTIVE,
|
||||
'accepted_at' => now(),
|
||||
'token' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Mailbox extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public static function defaultQuotaMb(): int
|
||||
{
|
||||
return (int) config('mailinfra.default_mailbox_quota_mb', 10240);
|
||||
}
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'website_id',
|
||||
'hosting_account_id',
|
||||
'domain_id',
|
||||
'email_domain_id',
|
||||
'display_name',
|
||||
'local_part',
|
||||
'address',
|
||||
'password_hash',
|
||||
'quota_mb',
|
||||
'disk_used_bytes',
|
||||
'last_usage_sync_at',
|
||||
'status',
|
||||
'is_paid',
|
||||
'payment_reference',
|
||||
'paid_at',
|
||||
'billing_type',
|
||||
'monthly_price',
|
||||
'subscription_started_at',
|
||||
'subscription_expires_at',
|
||||
'subscription_status',
|
||||
'maildir_path',
|
||||
'credentials_issued_at',
|
||||
'incoming_token',
|
||||
'inbound_enabled',
|
||||
'outbound_enabled',
|
||||
'outbound_provider',
|
||||
'smtp_host',
|
||||
'smtp_port',
|
||||
'smtp_encryption',
|
||||
'smtp_username',
|
||||
'smtp_password',
|
||||
'from_name',
|
||||
'from_address',
|
||||
'metadata',
|
||||
'webmail_secret',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'inbound_enabled' => 'boolean',
|
||||
'outbound_enabled' => 'boolean',
|
||||
'is_paid' => 'boolean',
|
||||
'paid_at' => 'datetime',
|
||||
'monthly_price' => 'decimal:2',
|
||||
'subscription_started_at' => 'datetime',
|
||||
'subscription_expires_at' => 'datetime',
|
||||
'smtp_password' => 'encrypted',
|
||||
'quota_mb' => 'integer',
|
||||
'disk_used_bytes' => 'integer',
|
||||
'last_usage_sync_at' => 'datetime',
|
||||
'credentials_issued_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password_hash',
|
||||
'webmail_secret',
|
||||
'incoming_token',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function domain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class);
|
||||
}
|
||||
|
||||
public function hostingAccount(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingAccount::class);
|
||||
}
|
||||
|
||||
public function emailDomain(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmailDomain::class);
|
||||
}
|
||||
|
||||
public function threads(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailThread::class);
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(MailMessage::class);
|
||||
}
|
||||
|
||||
public function isStandalone(): bool
|
||||
{
|
||||
return $this->website_id === null && $this->email_domain_id !== null;
|
||||
}
|
||||
|
||||
public function isWebsiteLinked(): bool
|
||||
{
|
||||
return $this->website_id !== null;
|
||||
}
|
||||
|
||||
public function getDomainHostAttribute(): ?string
|
||||
{
|
||||
if ($this->emailDomain) {
|
||||
return $this->emailDomain->domain;
|
||||
}
|
||||
|
||||
if ($this->domain) {
|
||||
return $this->domain->host;
|
||||
}
|
||||
|
||||
$parts = explode('@', $this->address ?? '', 2);
|
||||
return $parts[1] ?? null;
|
||||
}
|
||||
|
||||
public function getOwnerIdAttribute(): ?int
|
||||
{
|
||||
if ($this->user_id) {
|
||||
return $this->user_id;
|
||||
}
|
||||
|
||||
if ($this->website) {
|
||||
return $this->website->owner_user_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class NodeCapacityAlert extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_CAPACITY_WARNING = 'capacity_warning';
|
||||
public const TYPE_CAPACITY_CRITICAL = 'capacity_critical';
|
||||
public const TYPE_RESOURCE_HIGH = 'resource_high';
|
||||
public const TYPE_NEEDS_EXPANSION = 'needs_expansion';
|
||||
|
||||
protected $fillable = [
|
||||
'hosting_node_id',
|
||||
'alert_type',
|
||||
'current_accounts',
|
||||
'max_accounts',
|
||||
'capacity_percent',
|
||||
'resource_usage',
|
||||
'message',
|
||||
'is_resolved',
|
||||
'resolved_by',
|
||||
'resolved_at',
|
||||
'resolution_notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'current_accounts' => 'integer',
|
||||
'max_accounts' => 'integer',
|
||||
'capacity_percent' => 'decimal:2',
|
||||
'resource_usage' => 'array',
|
||||
'is_resolved' => 'boolean',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function hostingNode(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingNode::class);
|
||||
}
|
||||
|
||||
public function resolvedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by');
|
||||
}
|
||||
|
||||
public function scopeUnresolved($query)
|
||||
{
|
||||
return $query->where('is_resolved', false);
|
||||
}
|
||||
|
||||
public function scopeCritical($query)
|
||||
{
|
||||
return $query->whereIn('alert_type', [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]);
|
||||
}
|
||||
|
||||
public function resolve(User $admin, ?string $notes = null): void
|
||||
{
|
||||
$this->update([
|
||||
'is_resolved' => true,
|
||||
'resolved_by' => $admin->id,
|
||||
'resolved_at' => now(),
|
||||
'resolution_notes' => $notes,
|
||||
]);
|
||||
}
|
||||
|
||||
public function isCritical(): bool
|
||||
{
|
||||
return in_array($this->alert_type, [self::TYPE_CAPACITY_CRITICAL, self::TYPE_NEEDS_EXPANSION]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PlatformSetting extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'group',
|
||||
'key',
|
||||
'label',
|
||||
'description',
|
||||
'type',
|
||||
'value',
|
||||
'is_secret',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
'is_secret' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class PromoBanner extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_DOMAINS = 'domains';
|
||||
public const TYPE_HOSTING = 'hosting';
|
||||
public const TYPE_VPS = 'vps';
|
||||
public const TYPE_DEDICATED = 'dedicated';
|
||||
public const TYPE_EMAIL = 'email';
|
||||
public const TYPE_GENERAL = 'general';
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'product_type',
|
||||
'title',
|
||||
'description',
|
||||
'featured_image',
|
||||
'badge_text',
|
||||
'desktop_text',
|
||||
'mobile_text',
|
||||
'cta_text',
|
||||
'cta_url',
|
||||
'background_color',
|
||||
'text_color',
|
||||
'cta_background_color',
|
||||
'cta_text_color',
|
||||
'discount_percent',
|
||||
'package_ids',
|
||||
'starts_at',
|
||||
'ends_at',
|
||||
'is_active',
|
||||
'show_topbar',
|
||||
'show_on_homepage',
|
||||
'sort_order',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'package_ids' => 'array',
|
||||
'starts_at' => 'datetime',
|
||||
'ends_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
'show_topbar' => 'boolean',
|
||||
'show_on_homepage' => 'boolean',
|
||||
'discount_percent' => 'integer',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('starts_at')
|
||||
->orWhere('starts_at', '<=', now());
|
||||
})
|
||||
->where(function ($q) {
|
||||
$q->whereNull('ends_at')
|
||||
->orWhere('ends_at', '>=', now());
|
||||
});
|
||||
}
|
||||
|
||||
public function scopeForTopbar(Builder $query): Builder
|
||||
{
|
||||
return $query->active()->where('show_topbar', true);
|
||||
}
|
||||
|
||||
public static function getActiveTopbar(): ?self
|
||||
{
|
||||
return static::forTopbar()->latest()->first();
|
||||
}
|
||||
|
||||
public function scopeForHomepage(Builder $query): Builder
|
||||
{
|
||||
return $query->active()->where('show_on_homepage', true)->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public static function getHomepagePromos()
|
||||
{
|
||||
return static::forHomepage()->get();
|
||||
}
|
||||
|
||||
public static function getProductTypes(): array
|
||||
{
|
||||
return [
|
||||
self::TYPE_DOMAINS => 'Domains',
|
||||
self::TYPE_HOSTING => 'Hosting',
|
||||
self::TYPE_VPS => 'VPS',
|
||||
self::TYPE_DEDICATED => 'Dedicated Servers',
|
||||
self::TYPE_EMAIL => 'Email',
|
||||
self::TYPE_GENERAL => 'General',
|
||||
];
|
||||
}
|
||||
|
||||
public function getFeaturedImageUrlAttribute(): ?string
|
||||
{
|
||||
if (!$this->featured_image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($this->featured_image, 'http')) {
|
||||
return $this->featured_image;
|
||||
}
|
||||
|
||||
return asset('storage/' . $this->featured_image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProvisioningJob extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
'type',
|
||||
'provisionable_type',
|
||||
'provisionable_id',
|
||||
'provider',
|
||||
'status',
|
||||
'payload',
|
||||
'result',
|
||||
'error_message',
|
||||
'attempts',
|
||||
'max_attempts',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'next_retry_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'result' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'next_retry_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(function ($model) {
|
||||
$model->uuid = $model->uuid ?? Str::uuid()->toString();
|
||||
});
|
||||
}
|
||||
|
||||
public function provisionable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function markRunning(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_RUNNING,
|
||||
'started_at' => now(),
|
||||
'attempts' => $this->attempts + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markCompleted(array $result = []): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markFailed(string $error, bool $canRetry = true): void
|
||||
{
|
||||
$status = ($canRetry && $this->attempts < $this->max_attempts)
|
||||
? self::STATUS_PENDING
|
||||
: self::STATUS_FAILED;
|
||||
|
||||
$this->update([
|
||||
'status' => $status,
|
||||
'error_message' => $error,
|
||||
'next_retry_at' => $status === self::STATUS_PENDING ? now()->addMinutes(5) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PENDING)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('next_retry_at')
|
||||
->orWhere('next_retry_at', '<=', now());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProvisioningQueueItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'provisioning_queue';
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const PROVIDER_SHARED_NODE = 'shared_node';
|
||||
public const PROVIDER_CONTABO_VPS = 'contabo_vps';
|
||||
public const PROVIDER_CONTABO_DEDICATED = 'contabo_dedicated';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'status',
|
||||
'provider',
|
||||
'attempts',
|
||||
'max_attempts',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'error_message',
|
||||
'provisioning_log',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'attempts' => 'integer',
|
||||
'max_attempts' => 'integer',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'provisioning_log' => 'array',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function scopeQueued($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_QUEUED);
|
||||
}
|
||||
|
||||
public function scopeProcessing($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_PROCESSING);
|
||||
}
|
||||
|
||||
public function scopeFailed($query)
|
||||
{
|
||||
return $query->where('status', self::STATUS_FAILED);
|
||||
}
|
||||
|
||||
public function canRetry(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_FAILED && $this->attempts < $this->max_attempts;
|
||||
}
|
||||
|
||||
public function markAsProcessing(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_PROCESSING,
|
||||
'started_at' => now(),
|
||||
'attempts' => $this->attempts + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsCompleted(): void
|
||||
{
|
||||
$this->update([
|
||||
'status' => self::STATUS_COMPLETED,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $error): void
|
||||
{
|
||||
$log = $this->provisioning_log ?? [];
|
||||
$log[] = [
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'attempt' => $this->attempts,
|
||||
'error' => $error,
|
||||
];
|
||||
|
||||
$this->update([
|
||||
'status' => self::STATUS_FAILED,
|
||||
'error_message' => $error,
|
||||
'provisioning_log' => $log,
|
||||
]);
|
||||
}
|
||||
|
||||
public function addLog(string $message, string $level = 'info'): void
|
||||
{
|
||||
$log = $this->provisioning_log ?? [];
|
||||
$log[] = [
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
];
|
||||
|
||||
$this->update(['provisioning_log' => $log]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class RcServiceOrder extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const TYPE_SERVICE = 'service';
|
||||
public const TYPE_DOMAIN_REGISTRATION = 'domain_registration';
|
||||
public const TYPE_DOMAIN_TRANSFER = 'domain_transfer';
|
||||
public const TYPE_RENEWAL = 'renewal';
|
||||
|
||||
public const STATUS_CART = 'cart';
|
||||
public const STATUS_PENDING_PAYMENT = 'pending_payment';
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PROCESSING = 'processing';
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_SUSPENDED = 'suspended';
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
public const STATUS_EXPIRED = 'expired';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const PAYMENT_STATUS_UNPAID = 'unpaid';
|
||||
public const PAYMENT_STATUS_PENDING = 'pending';
|
||||
public const PAYMENT_STATUS_PAID = 'paid';
|
||||
public const PAYMENT_STATUS_FAILED = 'failed';
|
||||
public const PAYMENT_STATUS_REFUNDED = 'refunded';
|
||||
|
||||
public const FULFILLMENT_CART = 'cart';
|
||||
public const FULFILLMENT_PAYMENT_PENDING = 'payment_pending';
|
||||
public const FULFILLMENT_PAYMENT_RECEIVED = 'payment_received';
|
||||
public const FULFILLMENT_SUBMITTING = 'submitting';
|
||||
public const FULFILLMENT_AWAITING_VENDOR = 'awaiting_vendor';
|
||||
public const FULFILLMENT_MANUAL_REVIEW = 'manual_review';
|
||||
public const FULFILLMENT_FULFILLED = 'fulfilled';
|
||||
public const FULFILLMENT_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'category',
|
||||
'order_type',
|
||||
'domain_name',
|
||||
'rc_order_id',
|
||||
'rc_customer_id',
|
||||
'plan_id',
|
||||
'plan_name',
|
||||
'status',
|
||||
'payment_status',
|
||||
'fulfillment_status',
|
||||
'payment_reference',
|
||||
'amount_minor',
|
||||
'currency',
|
||||
'term_months',
|
||||
'term_years',
|
||||
'server_location',
|
||||
'ip_address',
|
||||
'access_url',
|
||||
'access_username',
|
||||
'expires_at',
|
||||
'paid_at',
|
||||
'submitted_at',
|
||||
'last_synced_at',
|
||||
'provisioned_at',
|
||||
'meta',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount_minor' => 'integer',
|
||||
'term_months' => 'integer',
|
||||
'term_years' => 'integer',
|
||||
'expires_at' => 'datetime',
|
||||
'paid_at' => 'datetime',
|
||||
'submitted_at' => 'datetime',
|
||||
'last_synced_at' => 'datetime',
|
||||
'provisioned_at' => 'datetime',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeVisibleToCustomer($query)
|
||||
{
|
||||
return $query->whereNotIn('status', [self::STATUS_CART, self::STATUS_PENDING_PAYMENT]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerAgent extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING_REGISTRATION = 'pending_registration';
|
||||
public const STATUS_ONLINE = 'online';
|
||||
public const STATUS_OFFLINE = 'offline';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'uuid',
|
||||
'status',
|
||||
'hostname',
|
||||
'agent_version',
|
||||
'platform',
|
||||
'capabilities',
|
||||
'metadata',
|
||||
'bootstrap_token_hash',
|
||||
'access_token_hash',
|
||||
'access_token_issued_at',
|
||||
'registered_at',
|
||||
'last_heartbeat_at',
|
||||
'last_seen_ip',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'capabilities' => 'array',
|
||||
'metadata' => 'array',
|
||||
'access_token_issued_at' => 'datetime',
|
||||
'registered_at' => 'datetime',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'bootstrap_token_hash',
|
||||
'access_token_hash',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function tasks(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerTask::class);
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class);
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServerDatabase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'server_site_id',
|
||||
'name',
|
||||
'username',
|
||||
'password_encrypted',
|
||||
'engine',
|
||||
'status',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'password_encrypted',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function site(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerSite::class, 'server_site_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServerFile extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'path',
|
||||
'kind',
|
||||
'exists',
|
||||
'size_bytes',
|
||||
'content_hash',
|
||||
'content_preview',
|
||||
'metadata',
|
||||
'last_synced_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'exists' => 'boolean',
|
||||
'size_bytes' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'last_synced_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerSite extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'latest_server_task_id',
|
||||
'domain_id',
|
||||
'domain',
|
||||
'document_root',
|
||||
'php_version',
|
||||
'php_socket',
|
||||
'status',
|
||||
'ssl_status',
|
||||
'ssl_provisioned_at',
|
||||
'ssl_expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
'ssl_provisioned_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function latestTask(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerTask::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function domainModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Domain::class, 'domain_id');
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class ServerTask extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_QUEUED = 'queued';
|
||||
public const STATUS_DISPATCHED = 'dispatched';
|
||||
public const STATUS_RUNNING = 'running';
|
||||
public const STATUS_COMPLETED = 'completed';
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const TYPE_DOMAIN_ADD = 'domain.add';
|
||||
public const TYPE_SSL_REQUEST = 'ssl.request';
|
||||
public const TYPE_SSL_RENEW = 'ssl.renew';
|
||||
public const TYPE_DATABASE_CREATE = 'database.create';
|
||||
public const TYPE_FILE_LIST = 'file.list';
|
||||
public const TYPE_FILE_READ = 'file.read';
|
||||
public const TYPE_FILE_WRITE = 'file.write';
|
||||
public const TYPE_SITE_DELETE = 'site.delete';
|
||||
public const TYPE_PHP_CONFIGURE = 'php.configure';
|
||||
public const TYPE_CRON_LIST = 'cron.list';
|
||||
public const TYPE_CRON_UPSERT = 'cron.upsert';
|
||||
public const TYPE_CRON_REMOVE = 'cron.remove';
|
||||
public const TYPE_LOG_READ = 'log.read';
|
||||
|
||||
protected $fillable = [
|
||||
'customer_hosting_order_id',
|
||||
'server_agent_id',
|
||||
'lock_token',
|
||||
'type',
|
||||
'status',
|
||||
'payload',
|
||||
'result',
|
||||
'progress',
|
||||
'attempt_count',
|
||||
'max_attempts',
|
||||
'retry_backoff_seconds',
|
||||
'stale_after_seconds',
|
||||
'queued_at',
|
||||
'available_at',
|
||||
'started_at',
|
||||
'completed_at',
|
||||
'failed_at',
|
||||
'last_heartbeat_at',
|
||||
'locked_at',
|
||||
'error_message',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload' => 'array',
|
||||
'result' => 'array',
|
||||
'queued_at' => 'datetime',
|
||||
'available_at' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'failed_at' => 'datetime',
|
||||
'last_heartbeat_at' => 'datetime',
|
||||
'locked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(CustomerHostingOrder::class, 'customer_hosting_order_id');
|
||||
}
|
||||
|
||||
public function agent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServerAgent::class, 'server_agent_id');
|
||||
}
|
||||
|
||||
public function sites(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerSite::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerDatabase::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function files(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServerFile::class, 'latest_server_task_id');
|
||||
}
|
||||
|
||||
public function canRetry(bool $retryable = true): bool
|
||||
{
|
||||
return $retryable && $this->attempt_count < max(1, (int) $this->max_attempts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
* Thin local mirror of the platform identity (auth.ladill.com owns users).
|
||||
* Keyed by public_id (the OIDC `sub`), upserted from SSO claims on login.
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password'];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
||||
}
|
||||
|
||||
/** Active team memberships (accounts this user can act within as a member). */
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(HostingTeamMember::class, 'user_id')
|
||||
->where('status', HostingTeamMember::STATUS_ACTIVE);
|
||||
}
|
||||
|
||||
/** Can this user act within the given account (own it, or active member)? */
|
||||
public function canAccessAccount(int $accountId): bool
|
||||
{
|
||||
return $accountId === $this->id
|
||||
|| $this->memberships()->where('account_id', $accountId)->exists();
|
||||
}
|
||||
|
||||
/** Owner Users for every account this user can act in (self first). */
|
||||
public function accessibleAccounts(): Collection
|
||||
{
|
||||
$ids = $this->memberships()->pluck('account_id')->all();
|
||||
|
||||
return collect([$this])->merge(self::whereIn('id', $ids)->get())->unique('id')->values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VpsInstance extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'hosting_plan_id',
|
||||
'name',
|
||||
'hostname',
|
||||
'provider',
|
||||
'provider_instance_id',
|
||||
'ip_address',
|
||||
'ipv6_address',
|
||||
'region',
|
||||
'datacenter',
|
||||
'image',
|
||||
'cpu_cores',
|
||||
'ram_mb',
|
||||
'disk_gb',
|
||||
'status',
|
||||
'power_status',
|
||||
'root_password_encrypted',
|
||||
'ssh_public_key',
|
||||
'tags',
|
||||
'metadata',
|
||||
'provisioned_at',
|
||||
'expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'metadata' => 'array',
|
||||
'provisioned_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
];
|
||||
|
||||
protected $hidden = ['root_password_encrypted'];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function plan(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(HostingPlan::class, 'hosting_plan_id');
|
||||
}
|
||||
|
||||
public function snapshots(): HasMany
|
||||
{
|
||||
return $this->hasMany(VpsSnapshot::class);
|
||||
}
|
||||
|
||||
public function isRunning(): bool
|
||||
{
|
||||
return $this->status === 'running';
|
||||
}
|
||||
|
||||
public function scopeRunning($query)
|
||||
{
|
||||
return $query->where('status', 'running');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Website extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'owner_user_id',
|
||||
'name',
|
||||
'slug',
|
||||
'subdomain',
|
||||
'status',
|
||||
'theme_tokens',
|
||||
'logo_path',
|
||||
'footer_logo_path',
|
||||
'favicon_path',
|
||||
'published_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'theme_tokens' => 'array',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function drafts(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteDraft::class);
|
||||
}
|
||||
|
||||
public function domains(): HasMany
|
||||
{
|
||||
return $this->hasMany(Domain::class);
|
||||
}
|
||||
|
||||
public function members(): HasMany
|
||||
{
|
||||
return $this->hasMany(WebsiteMember::class);
|
||||
}
|
||||
|
||||
public function subscriptions(): HasMany
|
||||
{
|
||||
return $this->hasMany(Subscription::class);
|
||||
}
|
||||
|
||||
public function leads(): HasMany
|
||||
{
|
||||
return $this->hasMany(Lead::class);
|
||||
}
|
||||
|
||||
public function mediaAssets(): HasMany
|
||||
{
|
||||
return $this->hasMany(MediaAsset::class);
|
||||
}
|
||||
|
||||
public function aiRequests(): HasMany
|
||||
{
|
||||
return $this->hasMany(AiRequest::class);
|
||||
}
|
||||
|
||||
public function publishes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Publish::class);
|
||||
}
|
||||
|
||||
public function paymentSetting()
|
||||
{
|
||||
return $this->hasOne(WebsitePaymentSetting::class);
|
||||
}
|
||||
|
||||
public function wallet()
|
||||
{
|
||||
return $this->hasOne(WebsiteWallet::class);
|
||||
}
|
||||
|
||||
public function ecommerceProducts(): HasMany
|
||||
{
|
||||
return $this->hasMany(EcommerceProduct::class);
|
||||
}
|
||||
|
||||
public function ecommerceOrders(): HasMany
|
||||
{
|
||||
return $this->hasMany(EcommerceOrder::class);
|
||||
}
|
||||
|
||||
public function blogPosts(): HasMany
|
||||
{
|
||||
return $this->hasMany(BlogPost::class);
|
||||
}
|
||||
|
||||
public function blogCategories(): HasMany
|
||||
{
|
||||
return $this->hasMany(BlogCategory::class);
|
||||
}
|
||||
|
||||
public function contactMessages(): HasMany
|
||||
{
|
||||
return $this->hasMany(ContactMessage::class);
|
||||
}
|
||||
|
||||
public function newsletterSubscribers(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterSubscriber::class);
|
||||
}
|
||||
|
||||
public function newsletterCampaigns(): HasMany
|
||||
{
|
||||
return $this->hasMany(NewsletterCampaign::class);
|
||||
}
|
||||
|
||||
public function mailboxes(): HasMany
|
||||
{
|
||||
return $this->hasMany(Mailbox::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class WebsiteMember extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'website_id',
|
||||
'user_id',
|
||||
'role',
|
||||
];
|
||||
|
||||
public function website(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Website::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class DomainVerifiedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("Domain Verified: {$this->domain->host} is now active!")
|
||||
->view('mail.notifications.domain-verified', [
|
||||
'domain' => $this->domain,
|
||||
'manageUrl' => route('user.domains.show', $this->domain),
|
||||
'emailUrl' => route('user.mailboxes.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'Domain Verified',
|
||||
'message' => "Your domain {$this->domain->host} has been verified and is now active.",
|
||||
'icon' => 'success',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class HostingActivatedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $planName,
|
||||
private readonly string $activationDate,
|
||||
private readonly ?string $domainName = null,
|
||||
private readonly ?string $manageUrl = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('Your hosting is now active!')
|
||||
->view('mail.notifications.hosting-activated', [
|
||||
'planName' => $this->planName,
|
||||
'activationDate' => $this->activationDate,
|
||||
'domainName' => $this->domainName,
|
||||
'manageUrl' => $this->manageUrl ?? route('hosting.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$message = "Your {$this->planName} hosting plan is now active";
|
||||
if ($this->domainName) {
|
||||
$message .= " for {$this->domainName}";
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => 'Hosting Activated',
|
||||
'message' => $message . '.',
|
||||
'icon' => 'hosting',
|
||||
'url' => $this->manageUrl ?? route('hosting.index'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingDeveloperAddedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $accountLabels
|
||||
*/
|
||||
public function __construct(
|
||||
private string $ownerName,
|
||||
private array $accountLabels,
|
||||
private ?string $setupUrl = null
|
||||
) {}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('You were added to a Ladill hosting team')
|
||||
->view('mail.notifications.hosting-developer-added', [
|
||||
'developer' => $notifiable,
|
||||
'ownerName' => $this->ownerName,
|
||||
'accountLabels' => $this->accountLabels,
|
||||
'setupUrl' => $this->setupUrl,
|
||||
'loginUrl' => route('login'),
|
||||
'dashboardUrl' => route('dashboard'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$accountCount = count($this->accountLabels);
|
||||
$scope = $accountCount === 1 ? $this->accountLabels[0] : $accountCount.' hosting accounts';
|
||||
|
||||
return [
|
||||
'title' => 'Hosting team access granted',
|
||||
'message' => "You were added by {$this->ownerName} to {$scope}.",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.single-domain'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly int $daysRemaining,
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->subjectLine())
|
||||
->view('mail.notifications.hosting-expiring', [
|
||||
'account' => $this->account,
|
||||
'daysRemaining' => $this->daysRemaining,
|
||||
'renewUrl' => route('hosting.accounts.show', $this->account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return [
|
||||
'title' => $this->headline(),
|
||||
'message' => "Hosting for {$label} expires in {$this->daysRemaining} days.",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
|
||||
private function subjectLine(): string
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return match (true) {
|
||||
$this->daysRemaining <= 1 => "Hosting expires tomorrow: {$label}",
|
||||
$this->daysRemaining <= 7 => "Urgent: hosting for {$label} expires in {$this->daysRemaining} days",
|
||||
default => "Hosting renewal reminder: {$label}",
|
||||
};
|
||||
}
|
||||
|
||||
private function headline(): string
|
||||
{
|
||||
return match (true) {
|
||||
$this->daysRemaining <= 1 => 'Hosting expires soon',
|
||||
$this->daysRemaining <= 7 => 'Urgent hosting renewal',
|
||||
default => 'Hosting renewal reminder',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingResourceWarningNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly string $subjectLine,
|
||||
private readonly string $messageBody
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject($this->subjectLine)
|
||||
->greeting('Hosting resource update')
|
||||
->line($this->messageBody)
|
||||
->line('Domain: ' . ($this->account->primary_domain ?: $this->account->username))
|
||||
->line('Resource state: ' . ucfirst((string) ($this->account->resource_status ?: 'active')))
|
||||
->action('Manage Hosting', route('hosting.accounts.show', $this->account));
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->subjectLine,
|
||||
'message' => $this->messageBody,
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class HostingSuspendedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly HostingAccount $account,
|
||||
private readonly string $reason,
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject('Hosting suspended: '.($this->account->primary_domain ?: $this->account->username))
|
||||
->view('mail.notifications.hosting-suspended', [
|
||||
'account' => $this->account,
|
||||
'reason' => $this->reason,
|
||||
'dashboardUrl' => route('hosting.accounts.show', $this->account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
$label = $this->account->primary_domain ?: $this->account->username;
|
||||
|
||||
return [
|
||||
'title' => 'Hosting suspended',
|
||||
'message' => "Hosting for {$label} has been suspended. {$this->reason}",
|
||||
'icon' => 'hosting',
|
||||
'url' => route('hosting.accounts.show', $this->account),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class SslExpiringNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain,
|
||||
private readonly int $daysUntilExpiry
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("SSL Certificate Expiring Soon: {$this->domain->host}")
|
||||
->view('mail.notifications.ssl-expiring', [
|
||||
'domain' => $this->domain,
|
||||
'daysUntilExpiry' => $this->daysUntilExpiry,
|
||||
'expiresAt' => $this->domain->ssl_expires_at,
|
||||
'manageUrl' => route('user.domains.show', $this->domain),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'SSL Certificate Expiring',
|
||||
'message' => "Your SSL certificate for {$this->domain->host} expires in {$this->daysUntilExpiry} days.",
|
||||
'icon' => 'warning',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Domain;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
|
||||
class SslProvisionedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Domain $domain
|
||||
) {
|
||||
}
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage())
|
||||
->subject("SSL Certificate Active: {$this->domain->host}")
|
||||
->view('mail.notifications.ssl-provisioned', [
|
||||
'domain' => $this->domain,
|
||||
'expiresAt' => $this->domain->ssl_expires_at,
|
||||
'websiteUrl' => "https://{$this->domain->host}",
|
||||
]);
|
||||
}
|
||||
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => 'SSL Certificate Active',
|
||||
'message' => "Your SSL certificate for {$this->domain->host} is now active. Your site is secure!",
|
||||
'icon' => 'ssl',
|
||||
'url' => route('user.domains.show', $this->domain),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\User;
|
||||
|
||||
class HostingAccountPolicy
|
||||
{
|
||||
public function viewAccount(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->isOwner($user, $account);
|
||||
}
|
||||
|
||||
public function viewPanel(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->isOwner($user, $account) || $this->isDeveloper($user, $account);
|
||||
}
|
||||
|
||||
public function manageFiles(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function deleteFiles(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function viewDomains(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageDomains(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageDatabases(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function managePhp(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function useTerminal(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageSsl(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageCron(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function viewLogs(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function clearLogs(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageApps(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function viewSettings(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->viewPanel($user, $account);
|
||||
}
|
||||
|
||||
public function manageSettings(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->isOwner($user, $account);
|
||||
}
|
||||
|
||||
public function changePassword(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return $this->isOwner($user, $account);
|
||||
}
|
||||
|
||||
private function isOwner(User $user, HostingAccount $account): bool
|
||||
{
|
||||
return (int) $account->user_id === (int) $user->id;
|
||||
}
|
||||
|
||||
private function isDeveloper(User $user, HostingAccount $account): bool
|
||||
{
|
||||
if ($this->isOwner($user, $account)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($account->relationLoaded('teamMembers')) {
|
||||
return $account->teamMembers->contains('user_id', $user->id);
|
||||
}
|
||||
|
||||
return $account->teamMembers()
|
||||
->where('user_id', $user->id)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Policies\HostingAccountPolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::policy(HostingAccount::class, HostingAccountPolicy::class);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user