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,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[compose.yaml]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,75 @@
|
||||
APP_NAME="Ladill Hosting"
|
||||
APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://hosting.ladill.com
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
AUTH_DOMAIN=auth.ladill.com
|
||||
ACCOUNT_DOMAIN=account.ladill.com
|
||||
HOSTING_DOMAIN=hosting.ladill.com
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=warning
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=ladill_hosting
|
||||
DB_USERNAME=ladill_hosting
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
CACHE_STORE=database
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
|
||||
BILLING_API_URL=https://ladill.com/api/billing
|
||||
BILLING_API_KEY_HOSTING=
|
||||
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_HOSTING=
|
||||
|
||||
DOMAIN_API_URL=https://ladill.com/api/domains
|
||||
DOMAIN_API_KEY_HOSTING=
|
||||
|
||||
LADILL_HOSTING_API_URL=https://httpapi.com/api
|
||||
LADILL_HOSTING_DEFAULT_PLAN_ID=
|
||||
HOSTING_PHPMYADMIN_URL=
|
||||
HOSTING_PHPMYADMIN_SSO_SECRET=
|
||||
|
||||
CONTABO_CLIENT_ID=
|
||||
CONTABO_CLIENT_SECRET=
|
||||
CONTABO_API_USER=
|
||||
CONTABO_API_PASSWORD=
|
||||
CONTABO_PRODUCT_CATALOG_ENDPOINT=
|
||||
CONTABO_PRICE_CACHE_TTL=3600
|
||||
|
||||
PDNS_API_URL=
|
||||
PDNS_API_KEY=
|
||||
|
||||
LADILL_RESELLERCLUB_TLD_PRODUCT_KEYS=com=domcno,net=domnet,org=domorg
|
||||
LADILL_RESELLERCLUB_CONTROL_PANEL_URL=https://manage.resellerclub.com/customer
|
||||
|
||||
AFIA_API_KEY=
|
||||
AFIA_PROVIDER=openai
|
||||
AFIA_MODEL=gpt-4o-mini
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
@@ -0,0 +1,92 @@
|
||||
name: Deploy Ladill Hosting
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-hosting
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: deploy
|
||||
env:
|
||||
NODE_ROOT: /tmp/ladill-node-22-r1
|
||||
NODE_VERSION: "22.14.0"
|
||||
RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-hosting-${{ gitea.run_id }}-${{ gitea.run_attempt }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-hosting
|
||||
steps:
|
||||
- name: Checkout
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
|
||||
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||
rm -rf "$WORKSPACE"
|
||||
mkdir -p "$WORKSPACE"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
git \
|
||||
-c credential.helper= \
|
||||
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Setup Node.js
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
x86_64|amd64) NODE_ARCH="x64" ;;
|
||||
aarch64|arm64) NODE_ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
|
||||
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
|
||||
fi
|
||||
"$NODE_ROOT/bin/node" -v
|
||||
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
|
||||
- name: Build release archive
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
cd "$WORKSPACE"
|
||||
printf '%s\n' "${{ gitea.sha }}" > REVISION
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
tar -czf "$RELEASE_ARCHIVE" \
|
||||
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
|
||||
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
|
||||
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
bash "$WORKSPACE/deploy/deploy.sh"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
shell: bash {0}
|
||||
run: |
|
||||
rm -rf "$WORKSPACE"
|
||||
rm -f "$RELEASE_ARCHIVE"
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,122 @@
|
||||
# Ladill Hosting — deploy & cutover runbook
|
||||
|
||||
Standalone app for **shared web hosting** at `hosting.ladill.com` (single-domain,
|
||||
multi-domain, WordPress). **VPS and dedicated servers** are a separate product —
|
||||
**Ladill Servers** at `servers.ladill.com` (not part of this app).
|
||||
It trusts `auth.ladill.com` for login (OIDC) and bills the one platform
|
||||
UserWallet over the §4-A Billing API. Provisioning runs against Contabo nodes
|
||||
and the shared hosting infrastructure (same stack as the monolith phase).
|
||||
|
||||
Mirrors the Ladill Domains / Email deployment model (app-slot + own DB + Gitea CI).
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Secret | Where it comes from |
|
||||
|---|---|
|
||||
| `CONTABO_CLIENT_ID` / `CONTABO_CLIENT_SECRET` / `CONTABO_API_USER` / `CONTABO_API_PASSWORD` | Contabo API credentials |
|
||||
| `PDNS_API_URL` / `PDNS_API_KEY` | PowerDNS (zone management) |
|
||||
| `PAYSTACK_SECRET_KEY` / `PAYSTACK_PUBLIC_KEY` | Paystack dashboard |
|
||||
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | step 3 |
|
||||
| `BILLING_API_KEY_HOSTING` | step 4 (must match platform env) |
|
||||
| `IDENTITY_API_KEY_HOSTING` | step 4 (must match platform env) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Gitea repo + CI
|
||||
|
||||
1. Create repo `isaacclad/ladill-hosting` in Gitea.
|
||||
2. Push this repo (`git remote add origin … && git push -u origin main`).
|
||||
3. Ensure an instance-level Actions runner exists (shared with Bird/Domains/Email).
|
||||
4. CI secrets (`.gitea/workflows/deploy.yml`): `SSH_HOST`, `SSH_PORT`, `SSH_USER`,
|
||||
`APP_ROOT` = `/var/www/ladill-hosting`, deploy SSH key.
|
||||
|
||||
## 2. Server app-slot + database
|
||||
|
||||
```bash
|
||||
sudo install -d -o deploy -g www-data /var/www/ladill-hosting
|
||||
|
||||
sudo mysql -e "CREATE DATABASE ladill_hosting CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
sudo mysql -e "CREATE USER 'ladill_hosting'@'127.0.0.1' IDENTIFIED BY '<pw>';"
|
||||
sudo mysql -e "GRANT ALL ON ladill_hosting.* TO 'ladill_hosting'@'127.0.0.1'; FLUSH PRIVILEGES;"
|
||||
```
|
||||
|
||||
Create `/var/www/ladill-hosting/.env` from `.env.example`, fill secrets,
|
||||
then `php artisan key:generate`.
|
||||
|
||||
## 3. Register the OIDC client (platform / auth.ladill.com)
|
||||
|
||||
```bash
|
||||
# On the monolith
|
||||
php artisan passport:client \
|
||||
--name="Ladill Hosting" \
|
||||
--redirect_uri="https://hosting.ladill.com/sso/callback"
|
||||
```
|
||||
|
||||
Set `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` in this app's `.env`.
|
||||
|
||||
## 4. Platform integration
|
||||
|
||||
On the **platform** `.env`:
|
||||
|
||||
```env
|
||||
BILLING_API_KEY_HOSTING=<same as this app>
|
||||
IDENTITY_API_KEY_HOSTING=<same as this app>
|
||||
RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel
|
||||
LADILL_HOSTING_APP_URL=https://hosting.ladill.com
|
||||
```
|
||||
|
||||
Then `php artisan config:cache` on the platform.
|
||||
|
||||
## 5. nginx + TLS
|
||||
|
||||
```bash
|
||||
sudo deployment/setup-service-subdomain-nginx.sh hosting --app ladill-hosting
|
||||
```
|
||||
|
||||
DNS `hosting.ladill.com` is already in `config/pdns.php` → `dns:sync-subdomains` on deploy.
|
||||
|
||||
## 6. First deploy
|
||||
|
||||
Push to `main` (CI) or run deploy manually, then:
|
||||
|
||||
```bash
|
||||
cd /var/www/ladill-hosting/current
|
||||
php artisan migrate --force
|
||||
php artisan db:seed --class=HostingProductSeeder --force
|
||||
php artisan config:cache route:cache view:cache
|
||||
chgrp -R www-data storage bootstrap/cache && chmod -R g+rwX storage bootstrap/cache
|
||||
```
|
||||
|
||||
Ensure a queue worker runs (`queue:work` via supervisor).
|
||||
|
||||
## 7. Zero-loss migration
|
||||
|
||||
```bash
|
||||
# a) On the platform — export customer hosting rows
|
||||
php artisan hosting:export --out=/tmp/hosting-export.json
|
||||
|
||||
# b) Copy to the hosting app server, dry-run import
|
||||
php artisan hosting:import /tmp/hosting-export.json
|
||||
|
||||
# c) Apply
|
||||
php artisan hosting:import /tmp/hosting-export.json --commit
|
||||
```
|
||||
|
||||
Re-running import is idempotent (upserts by original platform id).
|
||||
|
||||
## 8. Smoke test
|
||||
|
||||
- `https://hosting.ladill.com/up` → 200
|
||||
- Logged-out → SSO redirect to auth.ladill.com
|
||||
- Logged-in → dashboard lists imported accounts
|
||||
- Open an account → panel loads (files, domains, SSL)
|
||||
- Buy a test shared hosting plan end-to-end (wallet)
|
||||
- Log out → SLO clears hosting session
|
||||
|
||||
## 9. Decommission platform hosting UI (after burn-in)
|
||||
|
||||
Account sidebar + `/hosting/*` on account.ladill.com already redirect to
|
||||
`hosting.ladill.com`. After burn-in, remove remaining platform hosting models/jobs
|
||||
once the hosting app is the sole writer.
|
||||
@@ -0,0 +1,59 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
||||
|
||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
## Laravel Sponsors
|
||||
|
||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
||||
|
||||
### Premium Partners
|
||||
|
||||
- **[Vehikl](https://vehikl.com)**
|
||||
- **[Tighten Co.](https://tighten.co)**
|
||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
||||
- **[64 Robots](https://64robots.com)**
|
||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
||||
- **[Redberry](https://redberry.international/laravel-development)**
|
||||
- **[Active Logic](https://activelogic.com)**
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user