Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73b4857bd4 | ||
|
|
b92484c852 | ||
|
|
f266b8e738 | ||
|
|
7e86208c9f | ||
|
|
5a6bad80ce | ||
|
|
c40b9e63ed | ||
|
|
f2713c7c23 | ||
|
|
46168b1022 | ||
|
|
07bb8beb10 | ||
|
|
fcb2808ab1 | ||
|
|
0c4cf7bef1 | ||
|
|
7ceca971f5 | ||
|
|
a3ba2ac45b | ||
|
|
335c0fea61 | ||
|
|
ebacc3e409 | ||
|
|
b7f320ca30 | ||
|
|
bc199ccb43 | ||
|
|
40d3dc0fbe | ||
|
|
136cf8b719 | ||
|
|
460edb8719 | ||
|
|
c12c1cf7c3 | ||
|
|
550b55f4a7 | ||
|
|
62de32e247 | ||
|
|
bda976bc47 | ||
|
|
d311553745 | ||
|
|
fdb2d57010 | ||
|
|
335aaaf20e | ||
|
|
e48d7bbd42 | ||
|
|
2c9877a87c | ||
|
|
b0e1cfab4e | ||
|
|
2d5a0c3a4a | ||
|
|
3ca402fa25 | ||
|
|
9f31060e23 | ||
|
|
da45e46eb4 | ||
|
|
9194d9417f | ||
|
|
f491b38f0e | ||
|
|
1b8ec72a74 | ||
|
|
3a75eefeff | ||
|
|
e57bfb082a | ||
|
|
c9f0ee68f2 | ||
|
|
62caf10933 | ||
|
|
25c32ef5e3 | ||
|
|
86af6a4361 | ||
|
|
a8910507da | ||
|
|
9e1d4d5c3d | ||
|
|
d10b915059 | ||
|
|
9fffe7802c | ||
|
|
da6886e572 | ||
|
|
1e415a5906 | ||
|
|
75ba450938 | ||
|
|
d7b47e67fb | ||
|
|
01ba91d5ca | ||
|
|
28b4430c58 | ||
|
|
d54ae879b6 | ||
|
|
f7bba4d453 | ||
|
|
18d1e735d8 | ||
|
|
2a1228b40e | ||
|
|
e86f95ddc7 | ||
|
|
ff3959b734 | ||
|
|
7e5ca3dce4 | ||
|
|
091a8efd85 | ||
|
|
0961a00e1f | ||
|
|
77a048921c | ||
|
|
98dfa56b22 | ||
|
|
7574a035b6 | ||
|
|
5a54477128 | ||
|
|
4ba2e86cf5 | ||
|
|
739043755f | ||
|
|
36f7acf1e1 | ||
|
|
8fdc63c65d | ||
|
|
e6c2ebeee0 | ||
|
|
ae1c93b017 | ||
|
|
56c43a73af | ||
|
|
c1658d9e7f | ||
|
|
3ac89593fc | ||
|
|
537e34cbd7 | ||
|
|
44cfb1d9b9 | ||
|
|
e17cada9b6 | ||
|
|
bb421f3dbc | ||
|
|
53f38ba794 | ||
|
|
d85a7db057 | ||
|
|
869a53d168 | ||
|
|
2ff74c45ac | ||
|
|
f206f9bef5 | ||
|
|
107013bd04 | ||
|
|
174ead7d3e | ||
|
|
5e4109384e | ||
|
|
05466b6868 | ||
|
|
3c18d2f4b5 | ||
|
|
01325d3edf | ||
|
|
0bdc9513b0 | ||
|
|
ea7e46a260 | ||
|
|
8d076d9fe0 | ||
|
|
a47b469320 |
@@ -27,8 +27,14 @@ DB_DATABASE=ladill_hosting
|
||||
DB_USERNAME=ladill_hosting
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
SESSION_DRIVER=redis
|
||||
# Idle web session length in minutes (1440 = 24 hours of inactivity).
|
||||
SESSION_LIFETIME=1440
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=.ladill.com
|
||||
@@ -36,7 +42,7 @@ SESSION_DOMAIN=.ladill.com
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
CACHE_STORE=database
|
||||
CACHE_STORE=redis
|
||||
|
||||
LADILL_SSO_CLIENT_ID=
|
||||
LADILL_SSO_CLIENT_SECRET=
|
||||
@@ -47,6 +53,14 @@ BILLING_API_KEY_HOSTING=
|
||||
IDENTITY_API_URL=https://ladill.com/api
|
||||
IDENTITY_API_KEY_HOSTING=
|
||||
|
||||
# Platform admin API (account.ladill.com → hosting.ladill.com)
|
||||
PLATFORM_API_KEY_HOSTING=
|
||||
|
||||
# Ladill Domains API (owned-domain picker; domains.ladill.com)
|
||||
DOMAINS_API_URL=https://domains.ladill.com/api
|
||||
DOMAINS_API_KEY_HOSTING=
|
||||
|
||||
DOMAIN_REGISTRY_API_URL=https://ladill.com/api
|
||||
DOMAIN_API_URL=https://ladill.com/api/domains
|
||||
DOMAIN_API_KEY_HOSTING=
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ jobs:
|
||||
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 }}
|
||||
# act_runner v0.2.x does not expose gitea.run_attempt — use run_id only.
|
||||
RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}.tgz
|
||||
WORKSPACE: /tmp/${{ gitea.repository_owner }}-hosting-${{ gitea.run_id }}
|
||||
LADILL_APP_ROOT: /var/www/ladill-hosting
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -38,11 +39,17 @@ jobs:
|
||||
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
|
||||
"$REPO_URL" "$WORKSPACE"
|
||||
|
||||
- name: Setup Node.js
|
||||
- name: Build frontend assets
|
||||
shell: bash {0}
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
if [ ! -x "$NODE_ROOT/bin/node" ]; then
|
||||
cd "$WORKSPACE"
|
||||
if command -v npm >/dev/null 2>&1 && npm -v >/dev/null 2>&1; then
|
||||
NPM_BIN="$(command -v npm)"
|
||||
elif [ -x "$NODE_ROOT/bin/npm" ]; then
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||
else
|
||||
rm -rf "$NODE_ROOT"
|
||||
mkdir -p "$NODE_ROOT"
|
||||
case "$(uname -m)" in
|
||||
@@ -52,17 +59,12 @@ jobs:
|
||||
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"
|
||||
export PATH="$NODE_ROOT/bin:$PATH"
|
||||
NPM_BIN="$NODE_ROOT/bin/npm"
|
||||
fi
|
||||
"$NODE_ROOT/bin/node" -v
|
||||
"$NPM_BIN" ci --no-audit --no-fund
|
||||
"$NPM_BIN" run build
|
||||
|
||||
- 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}
|
||||
@@ -78,7 +80,7 @@ jobs:
|
||||
- name: Deploy release
|
||||
shell: bash {0}
|
||||
env:
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
|
||||
LADILL_RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}.tgz
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
|
||||
|
||||
@@ -65,10 +65,17 @@ On the **platform** `.env`:
|
||||
```env
|
||||
BILLING_API_KEY_HOSTING=<same as this app>
|
||||
IDENTITY_API_KEY_HOSTING=<same as this app>
|
||||
HOSTING_API_KEY_ACCOUNT=<same as PLATFORM_API_KEY_HOSTING below>
|
||||
RP_HOSTING_FRONTCHANNEL_LOGOUT=https://hosting.ladill.com/sso/logout-frontchannel
|
||||
LADILL_HOSTING_APP_URL=https://hosting.ladill.com
|
||||
```
|
||||
|
||||
On this app's `.env`:
|
||||
|
||||
```env
|
||||
PLATFORM_API_KEY_HOSTING=<same as platform HOSTING_API_KEY_ACCOUNT>
|
||||
```
|
||||
|
||||
Then `php artisan config:cache` on the platform.
|
||||
|
||||
## 5. nginx + TLS
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\ProvisionDomainSlaveZoneJob;
|
||||
use App\Jobs\ProvisionDomainZoneJob;
|
||||
use App\Models\Domain;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class EnsureDomainZoneCommand extends Command
|
||||
{
|
||||
protected $signature = 'domain:ensure-zone
|
||||
{host : Apex domain host, e.g. amenscientifichospital.com}
|
||||
{--sync : Run in-process instead of queueing}';
|
||||
|
||||
protected $description = 'Create or refresh the PowerDNS zone for a domain (fixes SERVFAIL/REFUSED on Ladill NS).';
|
||||
|
||||
public function handle(PowerDnsClient $pdns): int
|
||||
{
|
||||
$host = strtolower(trim((string) $this->argument('host'), ". \t\n\r\0\x0B"));
|
||||
$domain = Domain::query()->where('host', $host)->first();
|
||||
|
||||
if (! $domain) {
|
||||
$this->error("No Domain row found for {$host}. Link the domain in Hosting first.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Ensuring zone for %s (domain_id=%d, dns_mode=%s, onboarding=%s)',
|
||||
$domain->host,
|
||||
$domain->id,
|
||||
$domain->dns_mode ?: '(null)',
|
||||
$domain->onboarding_mode ?: '(null)'
|
||||
));
|
||||
|
||||
if (! $this->option('sync')) {
|
||||
ProvisionDomainZoneJob::dispatch($domain->id);
|
||||
$this->info('Queued ProvisionDomainZoneJob. Use --sync to run immediately.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
(new ProvisionDomainZoneJob($domain->id))->handle($pdns);
|
||||
$this->info('Zone provisioned on PowerDNS master.');
|
||||
ProvisionDomainSlaveZoneJob::dispatch($domain->id);
|
||||
$this->info('Queued slave zone sync.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->error('Failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\PlaceholderPrimaryDomainService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Legacy Sales Centre created Active CustomerHostingOrder rows with
|
||||
* pending-setup.local while also assigning a real HostingAccount — customers
|
||||
* then saw two hostings. Link/update those orders (or soft-delete duplicates).
|
||||
*/
|
||||
class RepairPlaceholderHostingOrdersCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:repair-placeholder-orders
|
||||
{--dry-run : Show changes without writing}
|
||||
{--delete-duplicates : Soft-delete Active placeholder orders that already have a HostingAccount for the same user}';
|
||||
|
||||
protected $description = 'Repair CustomerHostingOrders stuck on pending-setup.local after Hosting app assignment.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->with(['hostingAccount.sites'])
|
||||
->where(function ($q) {
|
||||
$q->where('domain_name', 'pending-setup.local')
|
||||
->orWhereNull('domain_name')
|
||||
->orWhere('domain_name', '');
|
||||
})
|
||||
->whereIn('status', [
|
||||
CustomerHostingOrder::STATUS_ACTIVE,
|
||||
CustomerHostingOrder::STATUS_SUSPENDED,
|
||||
CustomerHostingOrder::STATUS_PROVISIONING,
|
||||
])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
if ($orders->isEmpty()) {
|
||||
$this->info('No placeholder hosting orders found.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($orders as $order) {
|
||||
$account = $order->hostingAccount;
|
||||
if (! $account && $order->user_id) {
|
||||
$account = HostingAccount::query()
|
||||
->with('sites')
|
||||
->where('user_id', $order->user_id)
|
||||
->when($order->hosting_product_id, fn ($q) => $q->where('hosting_product_id', $order->hosting_product_id))
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
$realDomain = null;
|
||||
if ($account) {
|
||||
if (! $account->relationLoaded('sites')) {
|
||||
$account->load('sites');
|
||||
}
|
||||
$realDomain = $account->linkedDomainLabel();
|
||||
if (PlaceholderPrimaryDomainService::isPlaceholderDomain($realDomain)) {
|
||||
$realDomain = null;
|
||||
}
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'[order #%d] user=%s domain=%s account=%s → %s',
|
||||
$order->id,
|
||||
$order->user_id ?: '—',
|
||||
$order->domain_name ?: '(empty)',
|
||||
$account?->id ?: 'none',
|
||||
$realDomain ?: '(no real domain yet)'
|
||||
));
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($account && $this->option('delete-duplicates') && (int) $order->hosting_account_id !== (int) $account->id) {
|
||||
// Separate Active order + separate HostingAccount = duplicate UX.
|
||||
$order->update([
|
||||
'hosting_account_id' => $order->hosting_account_id ?: $account->id,
|
||||
'domain_name' => $realDomain ?: $order->domain_name,
|
||||
'server_username' => $order->server_username ?: $account->username,
|
||||
'status' => CustomerHostingOrder::STATUS_CANCELLED,
|
||||
'cancelled_at' => now(),
|
||||
'approval_notes' => trim(($order->approval_notes ? $order->approval_notes."\n" : '').
|
||||
'Auto-cancelled: superseded by Hosting account #'.$account->id),
|
||||
]);
|
||||
$order->delete();
|
||||
$this->info(' soft-deleted duplicate order');
|
||||
$changed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
if ($account && ! $order->hosting_account_id) {
|
||||
$updates['hosting_account_id'] = $account->id;
|
||||
}
|
||||
if ($realDomain) {
|
||||
$updates['domain_name'] = $realDomain;
|
||||
}
|
||||
if ($account && ! $order->server_username) {
|
||||
$updates['server_username'] = $account->username;
|
||||
}
|
||||
if ($account?->node?->ip_address && ! $order->server_ip) {
|
||||
$account->loadMissing('node');
|
||||
$updates['server_ip'] = $account->node?->ip_address;
|
||||
}
|
||||
|
||||
if ($updates !== []) {
|
||||
$order->update($updates);
|
||||
$this->info(' updated: '.json_encode($updates));
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run: no changes applied.');
|
||||
} else {
|
||||
$this->info("Updated {$changed} order(s).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\PlaceholderPrimaryDomainService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RepairPlaceholderPrimaryDomainsCommand extends Command
|
||||
{
|
||||
protected $signature = 'hosting:repair-placeholder-primaries
|
||||
{--account= : Limit to one hosting account id}
|
||||
{--dry-run : Show what would change without writing}';
|
||||
|
||||
protected $description = 'Promote real linked domains over pending-setup.local / placeholder primaries.';
|
||||
|
||||
public function handle(PlaceholderPrimaryDomainService $placeholders): int
|
||||
{
|
||||
$accounts = HostingAccount::query()
|
||||
->with(['sites', 'node'])
|
||||
->when($this->option('account'), fn ($q, $id) => $q->whereKey($id))
|
||||
->orderBy('id')
|
||||
->get()
|
||||
->filter(fn (HostingAccount $account) => $placeholders->accountNeedsPromotion($account)
|
||||
|| $account->sites->contains(
|
||||
fn ($site) => PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain)
|
||||
));
|
||||
|
||||
if ($accounts->isEmpty()) {
|
||||
$this->info('No accounts need placeholder primary repair.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$healed = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
$before = $account->primary_domain ?: '(null)';
|
||||
$this->line(sprintf(
|
||||
'[%d] %s primary=%s sites=%s',
|
||||
$account->id,
|
||||
$account->username,
|
||||
$before,
|
||||
$account->sites->pluck('domain')->implode(', ') ?: '(none)'
|
||||
));
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($placeholders->healAccount($account)) {
|
||||
$account->refresh();
|
||||
$this->info(sprintf(
|
||||
' → healed to primary=%s',
|
||||
$account->primary_domain ?: '(null)'
|
||||
));
|
||||
$healed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->warn('Dry run: no changes applied.');
|
||||
} else {
|
||||
$this->info("Healed {$healed} account(s).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,10 @@ class RunHostingTerminalWorkerCommand extends Command
|
||||
$launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows);
|
||||
|
||||
if ($this->shouldUseLocalExecution($account->node)) {
|
||||
return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand));
|
||||
// Confine the customer to a jailkit chroot (only their own home is
|
||||
// visible) via the vetted, sudo-scoped launcher. SECURITY: never run
|
||||
// an unjailed login shell here — it exposes the whole host filesystem.
|
||||
return new LocalPtyShellSession($this->buildJailedLaunchCommand($account, $relativePath));
|
||||
}
|
||||
|
||||
$ssh = $provider->connect($account->node);
|
||||
@@ -140,11 +143,39 @@ class RunHostingTerminalWorkerCommand extends Command
|
||||
throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.');
|
||||
}
|
||||
|
||||
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
|
||||
// SECURITY: jail the remote shell too. provider->connect() logs in as
|
||||
// root, so the launcher runs without sudo and chroots the customer into
|
||||
// the node's jail (only their own home visible) — never an unjailed shell.
|
||||
$remoteCommand = $this->buildJailedLaunchCommand($account, $relativePath, false);
|
||||
|
||||
return new RemoteSshShellSession($ssh, $remoteCommand, $cols, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local jailed shell: run the customer inside the jailkit chroot
|
||||
* (config('hosting.terminal_jail_launcher')) as their own user. The launcher
|
||||
* derives uid/gid, binds only that user's home into the jail, and chroots —
|
||||
* so the customer can never see the host filesystem (/var/www, other homes,
|
||||
* /etc, secrets). Invoked via sudo -n, scoped in /etc/sudoers.d/ladill-jailsh
|
||||
* because the terminal worker runs as www-data.
|
||||
*/
|
||||
private function buildJailedLaunchCommand(HostingAccount $account, string $relativePath, bool $sudo = true): string
|
||||
{
|
||||
$launcher = (string) config('hosting.terminal_jail_launcher', '/usr/local/sbin/ladill-jailsh');
|
||||
$rel = $relativePath !== '' ? $relativePath : '/public_html';
|
||||
|
||||
$parts = [];
|
||||
if ($sudo) {
|
||||
$parts[] = 'sudo';
|
||||
$parts[] = '-n';
|
||||
}
|
||||
$parts[] = escapeshellarg($launcher);
|
||||
$parts[] = escapeshellarg($account->username);
|
||||
$parts[] = escapeshellarg($rel);
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
private function buildUserShellBootstrap(HostingAccount $account, string $relativePath, int $cols, int $rows): string
|
||||
{
|
||||
$homeDirectory = "/home/{$account->username}";
|
||||
@@ -152,6 +183,14 @@ class RunHostingTerminalWorkerCommand extends Command
|
||||
? $homeDirectory
|
||||
: $homeDirectory.$relativePath;
|
||||
|
||||
// Short \W prompt + bracketed paste keep long pasted commands readable in
|
||||
// the narrow browser terminal used by all hosting clients.
|
||||
$inputrc = implode("\n", [
|
||||
'set enable-bracketed-paste on',
|
||||
'set horizontal-scroll-mode on',
|
||||
'set blink-matching-paren on',
|
||||
]);
|
||||
|
||||
return implode('; ', [
|
||||
'export HOME='.escapeshellarg($homeDirectory),
|
||||
'export USER='.escapeshellarg($account->username),
|
||||
@@ -161,7 +200,9 @@ class RunHostingTerminalWorkerCommand extends Command
|
||||
'export COLORTERM=truecolor',
|
||||
'export COLUMNS='.(int) $cols,
|
||||
'export LINES='.(int) $rows,
|
||||
'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '),
|
||||
'export PS1='.escapeshellarg($account->username.'@ladill:\W\$ '),
|
||||
'printf %s '.escapeshellarg($inputrc).' > /tmp/ladill-term-inputrc-$$',
|
||||
'export INPUTRC=/tmp/ladill-term-inputrc-$$',
|
||||
'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',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\AdminHostingAssignmentService;
|
||||
use App\Services\Hosting\HostingResourcePolicyService;
|
||||
use App\Services\Platform\PlatformUserResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PlatformHostingController extends Controller
|
||||
{
|
||||
public function products(AdminHostingAssignmentService $assignments): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => $assignments->listProducts(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function index(string $publicId, PlatformUserResolver $users, AdminHostingAssignmentService $assignments): JsonResponse
|
||||
{
|
||||
$owner = $users->resolveOrCreate($publicId);
|
||||
if (! $owner) {
|
||||
return response()->json(['data' => []]);
|
||||
}
|
||||
|
||||
$accounts = HostingAccount::query()
|
||||
->where('user_id', $owner->id)
|
||||
->with('product')
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn (HostingAccount $account) => $assignments->serializeAccount($account))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return response()->json(['data' => $accounts]);
|
||||
}
|
||||
|
||||
public function assign(
|
||||
Request $request,
|
||||
string $publicId,
|
||||
PlatformUserResolver $users,
|
||||
AdminHostingAssignmentService $assignments,
|
||||
): JsonResponse {
|
||||
$validated = $request->validate([
|
||||
'hosting_product_id' => ['required', 'integer', 'exists:hosting_products,id'],
|
||||
'duration_months' => ['required', 'integer', 'min:1', 'max:120'],
|
||||
'primary_domain' => ['nullable', 'string', 'max:255'],
|
||||
'username' => ['nullable', 'string', 'max:32'],
|
||||
'notes' => ['nullable', 'string', 'max:1000'],
|
||||
'owner_email' => ['required', 'email', 'max:255'],
|
||||
'owner_name' => ['nullable', 'string', 'max:255'],
|
||||
'assigned_by_admin' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$owner = $users->resolveOrCreate(
|
||||
$publicId,
|
||||
(string) $validated['owner_email'],
|
||||
(string) ($validated['owner_name'] ?? ''),
|
||||
);
|
||||
|
||||
if (! $owner) {
|
||||
return response()->json(['error' => 'User not found.'], 404);
|
||||
}
|
||||
|
||||
$result = $assignments->assign($owner, $validated);
|
||||
|
||||
return response()->json(['data' => $result], 201);
|
||||
}
|
||||
|
||||
public function updateDuration(
|
||||
Request $request,
|
||||
int $account,
|
||||
AdminHostingAssignmentService $assignments,
|
||||
): JsonResponse {
|
||||
$validated = $request->validate([
|
||||
'duration_months' => ['required', 'integer', 'min:1', 'max:120'],
|
||||
'admin_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$hostingAccount = HostingAccount::query()->findOrFail($account);
|
||||
|
||||
$data = $assignments->updateDuration(
|
||||
$hostingAccount,
|
||||
(int) $validated['duration_months'],
|
||||
isset($validated['admin_id']) ? (int) $validated['admin_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function renew(
|
||||
Request $request,
|
||||
int $account,
|
||||
AdminHostingAssignmentService $assignments,
|
||||
): JsonResponse {
|
||||
$validated = $request->validate([
|
||||
'admin_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$hostingAccount = HostingAccount::query()->findOrFail($account);
|
||||
|
||||
$data = $assignments->renew(
|
||||
$hostingAccount,
|
||||
isset($validated['admin_id']) ? (int) $validated['admin_id'] : null,
|
||||
);
|
||||
|
||||
return response()->json(['data' => $data]);
|
||||
}
|
||||
|
||||
public function unsuspend(
|
||||
int $account,
|
||||
AdminHostingAssignmentService $assignments,
|
||||
HostingResourcePolicyService $resourcePolicy,
|
||||
): JsonResponse {
|
||||
$hostingAccount = HostingAccount::query()->findOrFail($account);
|
||||
$assignments->unsuspend($hostingAccount, $resourcePolicy);
|
||||
|
||||
return response()->json(['data' => ['ok' => true]]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\HostingTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -88,10 +88,7 @@ class SsoLoginController extends Controller
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
]);
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
|
||||
@@ -121,7 +118,7 @@ class SsoLoginController extends Controller
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
HostingTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
@@ -141,6 +138,18 @@ class SsoLoginController extends Controller
|
||||
return redirect()->away($this->defaultSignedOutUrl());
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
|
||||
@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Hosting\HostingAccessResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -55,9 +55,7 @@ class AfiaController extends Controller
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
|
||||
|
||||
$accounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?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.');
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Jobs\ProvisionHostingSslJob;
|
||||
use App\Models\AppInstallation;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\Domain;
|
||||
use App\Models\DomainDnsRecord;
|
||||
use App\Models\HostedDatabase;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
@@ -15,6 +16,7 @@ use App\Models\HostingOrder;
|
||||
use App\Models\RcServiceOrder;
|
||||
use App\Models\Website;
|
||||
use App\Models\WebsiteMember;
|
||||
use App\Services\Dns\PowerDnsClient;
|
||||
use App\Services\Domain\DomainDnsBlueprintService;
|
||||
use App\Services\Domain\DomainVerificationService;
|
||||
use App\Services\Hosting\Contracts\PanelRuntimeInterface;
|
||||
@@ -1074,10 +1076,21 @@ class HostingPanelController extends Controller
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$ownedDomains = $this->availableHostingDomainsForUser($request->user()->id, $account);
|
||||
$parentSites = $account->sites
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->sortBy(fn (HostedSite $site) => [$site->type !== 'primary', $site->domain])
|
||||
->values();
|
||||
|
||||
return view('hosting.panel.domains', [
|
||||
'account' => $account,
|
||||
'ownedDomains' => $ownedDomains,
|
||||
'parentSites' => $parentSites,
|
||||
'maxDomains' => $account->maxDomainsLimit(),
|
||||
'maxSubdomains' => $account->maxSubdomainsLimit(),
|
||||
'domainSitesCount' => $account->domainSitesCount(),
|
||||
'subdomainSitesCount' => $account->subdomainSitesCount(),
|
||||
'canAddDomain' => $account->canAddDomain(),
|
||||
'canAddSubdomain' => $account->canAddSubdomain(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1091,12 +1104,15 @@ class HostingPanelController extends Controller
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites']);
|
||||
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
if ($account->sites->count() >= $maxDomains) {
|
||||
if (! $account->canAddDomain()) {
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$message = $maxDomains === -1
|
||||
? 'You cannot add more domains right now.'
|
||||
: "You have reached the maximum number of domains ({$maxDomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422);
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
return back()->with('error', "You have reached the maximum number of domains ({$maxDomains}).");
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$isOwnedDomain = $request->boolean('is_owned_domain');
|
||||
@@ -1116,6 +1132,18 @@ class HostingPanelController extends Controller
|
||||
$onboardingMode = $isOwnedDomain
|
||||
? Domain::MODE_NS_AUTO
|
||||
: ($validated['onboarding_mode'] ?? Domain::MODE_NS_AUTO);
|
||||
|
||||
if ($isOwnedDomain) {
|
||||
$owned = $this->availableHostingDomainsForUser($request->user()->id, $account);
|
||||
if (! $owned->contains($domainHost)) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'That domain is not in your Ladill account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'That domain is not in your Ladill account.');
|
||||
}
|
||||
}
|
||||
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
$docRoot = ! empty($validated['document_root'])
|
||||
? "/home/{$account->username}/" . ltrim($validated['document_root'], '/')
|
||||
@@ -1134,16 +1162,23 @@ class HostingPanelController extends Controller
|
||||
|
||||
// Restore the soft-deleted site and recreate nginx config
|
||||
$existingSite->restore();
|
||||
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
|
||||
$promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost);
|
||||
|
||||
$existingSite->fill([
|
||||
'document_root' => $docRoot,
|
||||
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
||||
'status' => 'active',
|
||||
'type' => $existingSite->type ?: 'addon',
|
||||
'type' => $promoteToPrimary ? 'primary' : ($existingSite->type ?: 'addon'),
|
||||
])->save();
|
||||
try {
|
||||
$existingSite->loadMissing(['account.node']);
|
||||
$this->runtimeFor($account)->addSite($existingSite);
|
||||
|
||||
if ($promoteToPrimary) {
|
||||
$placeholders->promoteSite($account, $existingSite);
|
||||
}
|
||||
|
||||
// Queue SSL provisioning for restored domain
|
||||
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
|
||||
|
||||
@@ -1228,12 +1263,15 @@ class HostingPanelController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
|
||||
$promoteToPrimary = $placeholders->shouldBecomePrimary($account, $domainHost);
|
||||
|
||||
$site = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $domain->id,
|
||||
'domain' => $domainHost,
|
||||
'document_root' => $docRoot,
|
||||
'type' => 'addon',
|
||||
'type' => $promoteToPrimary ? 'primary' : 'addon',
|
||||
'php_version' => $account->php_version ?? '8.2',
|
||||
'ssl_enabled' => false,
|
||||
'status' => 'active',
|
||||
@@ -1242,6 +1280,10 @@ class HostingPanelController extends Controller
|
||||
try {
|
||||
$this->runtimeFor($account)->addSite($site);
|
||||
|
||||
if ($promoteToPrimary) {
|
||||
$placeholders->promoteSite($account, $site);
|
||||
}
|
||||
|
||||
// Queue DNS zone provisioning (manual pack is local-only).
|
||||
if ($onboardingMode === Domain::MODE_MANUAL_DNS) {
|
||||
$verification->prepareManualDnsPack($domain);
|
||||
@@ -1271,15 +1313,172 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function addSubdomain(
|
||||
Request $request,
|
||||
HostingAccount $account,
|
||||
PowerDnsClient $pdns,
|
||||
): RedirectResponse|JsonResponse {
|
||||
$this->authorizeForRequestUser($request, 'manageDomains', $account);
|
||||
$account->load(['node', 'product', 'sites.domain']);
|
||||
|
||||
if (! $account->canAddSubdomain()) {
|
||||
$maxSubdomains = $account->maxSubdomainsLimit();
|
||||
$message = $maxSubdomains === -1
|
||||
? 'You cannot add more subdomains right now.'
|
||||
: "You have reached the maximum number of subdomains ({$maxSubdomains}).";
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', $message);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'parent_site_id' => ['required', 'integer'],
|
||||
'subdomain' => ['required', 'string', 'max:63', 'regex:/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/'],
|
||||
'document_root' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
$label = strtolower(trim($validated['subdomain']));
|
||||
$reserved = ['www', 'mail', 'ftp', 'ns1', 'ns2'];
|
||||
if (in_array($label, $reserved, true)) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => "The subdomain \"{$label}\" is reserved."], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', "The subdomain \"{$label}\" is reserved.");
|
||||
}
|
||||
|
||||
$parent = $account->sites
|
||||
->first(fn (HostedSite $site) => (int) $site->id === (int) $validated['parent_site_id']
|
||||
&& in_array($site->type, ['primary', 'addon'], true));
|
||||
|
||||
if (! $parent) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Choose a parent domain attached to this hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Choose a parent domain attached to this hosting account.');
|
||||
}
|
||||
|
||||
$fqdn = $label.'.'.strtolower(trim((string) $parent->domain));
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
$docRoot = ! empty($validated['document_root'])
|
||||
? '/home/'.$account->username.'/'.ltrim($validated['document_root'], '/')
|
||||
: '/home/'.$account->username.'/public_html/'.$fqdn;
|
||||
|
||||
$existingSite = HostedSite::withTrashed()->where('domain', $fqdn)->first();
|
||||
if ($existingSite) {
|
||||
if ($existingSite->trashed() && (int) $existingSite->hosting_account_id === (int) $account->id) {
|
||||
$existingSite->restore();
|
||||
$existingSite->fill([
|
||||
'document_root' => $docRoot,
|
||||
'php_version' => $account->php_version ?? $existingSite->php_version ?? '8.2',
|
||||
'status' => 'active',
|
||||
'type' => 'subdomain',
|
||||
'domain_id' => $parent->domain_id,
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$existingSite->loadMissing(['account.node']);
|
||||
$this->runtimeFor($account)->addSite($existingSite);
|
||||
$dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
if (! empty($dnsResult['domain_id']) && (int) $existingSite->domain_id !== (int) $dnsResult['domain_id']) {
|
||||
$existingSite->update(['domain_id' => $dnsResult['domain_id']]);
|
||||
}
|
||||
ProvisionHostingSslJob::dispatch($existingSite->id)->delay(now()->addMinutes(2));
|
||||
|
||||
$message = match ($dnsResult['status']) {
|
||||
'managed' => "Subdomain {$fqdn} has been restored. DNS was updated on Ladill nameservers; SSL will provision automatically.",
|
||||
'managed_failed' => "Subdomain {$fqdn} has been restored. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains.",
|
||||
default => "Subdomain {$fqdn} has been restored. Because this domain uses external DNS, add an A record for {$fqdn} → {$serverIp}. SSL will provision automatically.",
|
||||
};
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => $message]);
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
} catch (\Exception $e) {
|
||||
$existingSite->delete();
|
||||
Log::error('Failed to restore subdomain nginx config: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to restore subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to restore subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
if ((int) $existingSite->hosting_account_id !== (int) $account->id) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'This subdomain is already in use on another hosting account.'], 422);
|
||||
}
|
||||
|
||||
return back()->with('error', 'This subdomain is already in use on another hosting account.');
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => "Subdomain {$fqdn} is already linked to this hosting account."]);
|
||||
}
|
||||
|
||||
return back()->with('success', "Subdomain {$fqdn} is already linked to this hosting account.");
|
||||
}
|
||||
|
||||
$site = HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $parent->domain_id,
|
||||
'domain' => $fqdn,
|
||||
'document_root' => $docRoot,
|
||||
'type' => 'subdomain',
|
||||
'php_version' => $account->php_version ?? '8.2',
|
||||
'ssl_enabled' => false,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->addSite($site);
|
||||
$dnsResult = $this->provisionSubdomainDns($account, $parent, $label, $fqdn, $serverIp, $pdns);
|
||||
if (! empty($dnsResult['domain_id']) && (int) $site->domain_id !== (int) $dnsResult['domain_id']) {
|
||||
$site->update(['domain_id' => $dnsResult['domain_id']]);
|
||||
}
|
||||
ProvisionHostingSslJob::dispatch($site->id)->delay(now()->addMinutes(2));
|
||||
|
||||
$message = match ($dnsResult['status']) {
|
||||
'managed' => "Subdomain {$fqdn} created. DNS was updated on Ladill nameservers; SSL will provision automatically.",
|
||||
'managed_failed' => "Subdomain {$fqdn} created. Ladill nameservers are in use, but we could not publish the A record automatically. Check DNS for {$parent->domain} in Ladill Domains (expect {$fqdn} → {$serverIp}). SSL will retry once DNS resolves.",
|
||||
default => "Subdomain {$fqdn} created. Because this domain uses external DNS, add an A record for {$fqdn} → {$serverIp}, then SSL will provision automatically.",
|
||||
};
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['success' => true, 'message' => $message]);
|
||||
}
|
||||
|
||||
return back()->with('success', $message);
|
||||
} catch (\Exception $e) {
|
||||
$site->delete();
|
||||
Log::error('Failed to add subdomain: '.$e->getMessage());
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['message' => 'Failed to create subdomain. Please try again.'], 500);
|
||||
}
|
||||
|
||||
return back()->with('error', 'Failed to create subdomain. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(Request $request, HostingAccount $account, HostedSite $site): RedirectResponse
|
||||
{
|
||||
abort_unless((int) $account->user_id === (int) $request->user()->id, 403);
|
||||
$this->ensureSiteBelongsToAccount($site, $account);
|
||||
|
||||
$account->load(['node']);
|
||||
$site->loadMissing('domain');
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->removeSite($site);
|
||||
$this->cleanupSubdomainDns($site);
|
||||
$site->delete();
|
||||
|
||||
return back()->with('success', 'Domain removed successfully.');
|
||||
@@ -1289,6 +1488,137 @@ class HostingPanelController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status: 'managed'|'managed_failed'|'manual', domain_id: ?int}
|
||||
*/
|
||||
private function provisionSubdomainDns(
|
||||
HostingAccount $account,
|
||||
HostedSite $parent,
|
||||
string $label,
|
||||
string $fqdn,
|
||||
string $serverIp,
|
||||
PowerDnsClient $pdns,
|
||||
): array {
|
||||
$parentDomain = $this->resolveParentDomainRecord($parent);
|
||||
|
||||
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
|
||||
return ['status' => 'manual', 'domain_id' => $parentDomain?->id];
|
||||
}
|
||||
|
||||
// Heal legacy rows that were on ns_auto but never stamped dns_mode=managed.
|
||||
if ((string) $parentDomain->dns_mode !== 'managed') {
|
||||
$parentDomain->update(['dns_mode' => 'managed']);
|
||||
}
|
||||
|
||||
if (! $parent->domain_id) {
|
||||
$parent->update(['domain_id' => $parentDomain->id]);
|
||||
}
|
||||
|
||||
try {
|
||||
$pdns->upsertRecords((string) $parentDomain->host, [[
|
||||
'name' => $fqdn,
|
||||
'type' => 'A',
|
||||
'ttl' => 3600,
|
||||
'contents' => [$serverIp],
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to provision subdomain DNS record', [
|
||||
'fqdn' => $fqdn,
|
||||
'parent_domain_id' => $parentDomain->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return ['status' => 'managed_failed', 'domain_id' => $parentDomain->id];
|
||||
}
|
||||
|
||||
// Local registry is best-effort; PowerDNS publish is what matters for Ladill NS.
|
||||
try {
|
||||
DomainDnsRecord::query()->updateOrCreate(
|
||||
[
|
||||
'domain_id' => $parentDomain->id,
|
||||
'name' => $label,
|
||||
'type' => 'A',
|
||||
'value' => $serverIp,
|
||||
],
|
||||
[
|
||||
'ttl' => 3600,
|
||||
'source' => 'hosting_subdomain',
|
||||
'status' => 'active',
|
||||
'notes' => 'Subdomain for hosting account '.$account->id,
|
||||
'last_checked_at' => now(),
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('Subdomain DNS published but local domain_dns_records sync failed', [
|
||||
'fqdn' => $fqdn,
|
||||
'parent_domain_id' => $parentDomain->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return ['status' => 'managed', 'domain_id' => $parentDomain->id];
|
||||
}
|
||||
|
||||
private function resolveParentDomainRecord(HostedSite $parent): ?Domain
|
||||
{
|
||||
if ($parent->domain_id) {
|
||||
$byId = Domain::query()->find($parent->domain_id);
|
||||
if ($byId) {
|
||||
return $byId;
|
||||
}
|
||||
}
|
||||
|
||||
$host = strtolower(trim((string) $parent->getAttribute('domain')));
|
||||
if ($host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Domain::query()->where('host', $host)->first();
|
||||
}
|
||||
|
||||
private function cleanupSubdomainDns(HostedSite $site): void
|
||||
{
|
||||
if ($site->type !== 'subdomain' || ! $site->domain_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$parentDomain = Domain::query()->find($site->domain_id);
|
||||
if (! $parentDomain || ! $parentDomain->usesManagedDns()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$host = strtolower(trim((string) $site->getAttribute('domain')));
|
||||
$apex = strtolower((string) $parentDomain->host);
|
||||
$suffix = '.'.$apex;
|
||||
if (! str_ends_with($host, $suffix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$label = substr($host, 0, -strlen($suffix));
|
||||
if ($label === '' || str_contains($label, '.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
app(PowerDnsClient::class)->deleteRecords($apex, [[
|
||||
'name' => $host,
|
||||
'type' => 'A',
|
||||
]]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('Best-effort subdomain DNS cleanup failed', [
|
||||
'domain' => $host,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
DomainDnsRecord::query()
|
||||
->where('domain_id', $parentDomain->id)
|
||||
->where('name', $label)
|
||||
->where('type', 'A')
|
||||
->where('source', 'hosting_subdomain')
|
||||
->delete();
|
||||
}
|
||||
|
||||
private function availableHostingDomainsForUser(int $userId, HostingAccount $account): \Illuminate\Support\Collection
|
||||
{
|
||||
$linkedDomains = HostedSite::whereNotNull('domain')
|
||||
@@ -1297,35 +1627,13 @@ class HostingPanelController extends Controller
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host');
|
||||
$user = \App\Models\User::query()->find($userId);
|
||||
$owned = $user
|
||||
? app(\App\Services\Domains\LadillDomainsClient::class)->ownedForUser((string) $user->public_id)
|
||||
: [];
|
||||
|
||||
$registeredDomains = RcServiceOrder::where('user_id', $userId)
|
||||
->whereIn('order_type', [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER])
|
||||
->where('status', RcServiceOrder::STATUS_ACTIVE)
|
||||
->whereNotNull('domain_name')
|
||||
->pluck('domain_name');
|
||||
|
||||
$customerHostingDomains = CustomerHostingOrder::where('user_id', $userId)
|
||||
->whereNotIn('status', [
|
||||
CustomerHostingOrder::STATUS_PENDING_PAYMENT,
|
||||
CustomerHostingOrder::STATUS_CANCELLED,
|
||||
CustomerHostingOrder::STATUS_FAILED,
|
||||
])
|
||||
->whereNotNull('domain_name')
|
||||
->pluck('domain_name');
|
||||
|
||||
$hostingAccountDomains = HostingAccount::where('user_id', $userId)
|
||||
->whereNotNull('primary_domain')
|
||||
->pluck('primary_domain');
|
||||
|
||||
return $domainRegistryHosts
|
||||
->merge($registeredDomains)
|
||||
->merge($customerHostingDomains)
|
||||
->merge($hostingAccountDomains)
|
||||
->map(fn ($d) => strtolower(trim((string) $d)))
|
||||
->filter(fn ($d) => (bool) preg_match('/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/', $d))
|
||||
return collect($owned)
|
||||
->reject(fn ($d) => in_array($d, $linkedDomains, true))
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
}
|
||||
@@ -1484,17 +1792,33 @@ class HostingPanelController extends Controller
|
||||
|
||||
try {
|
||||
$this->runtimeFor($account)->requestLetsEncryptCertificate($site);
|
||||
$expiresAt = now()->addDays(90);
|
||||
$site->update([
|
||||
'ssl_enabled' => true,
|
||||
'ssl_status' => 'issued',
|
||||
'ssl_provisioned_at' => now(),
|
||||
'ssl_expires_at' => $expiresAt,
|
||||
'ssl_error' => null,
|
||||
]);
|
||||
|
||||
if ($site->domain_id) {
|
||||
Domain::query()->whereKey($site->domain_id)->update([
|
||||
'ssl_status' => 'active',
|
||||
'ssl_expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
app(\App\Services\Ssl\CentralSslRegistry::class)->register($site->domain, $expiresAt);
|
||||
|
||||
return back()->with('success', "SSL certificate issued for {$site->domain} successfully.");
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to request SSL: " . $e->getMessage());
|
||||
|
||||
$site->update([
|
||||
'ssl_status' => 'failed',
|
||||
'ssl_error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$message = trim($e->getMessage());
|
||||
|
||||
return back()->with(
|
||||
|
||||
@@ -5,11 +5,11 @@ 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\HostingAccessResolver;
|
||||
use App\Services\Hosting\HostingOrderFulfillmentService;
|
||||
use App\Services\Hosting\HostingPlanChangeService;
|
||||
use App\Services\Hosting\HostingRenewalCheckoutService;
|
||||
@@ -54,18 +54,18 @@ class HostingProductController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// New Hosting Orders
|
||||
// In-flight orders only — Active accounts belong under Hosting Accounts
|
||||
// (legacy Sales Centre left Active pending-setup.local orders that duplicated accounts).
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->inFlight()
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'domain', 'hostingAccount', 'hostingNode'])
|
||||
->with(['product', 'domain', 'hostingAccount.sites', 'hostingNode'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Admin-assigned Hosting Accounts
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
@@ -109,6 +109,19 @@ class HostingProductController extends Controller
|
||||
$nativeForm = $this->nativeHostingProductForm($type);
|
||||
$userDomains = $this->getUserDomains($user);
|
||||
|
||||
$pendingStatuses = ['pending_payment', 'pending_approval', 'approved', 'provisioning'];
|
||||
$heroStats = [
|
||||
'accounts' => $hostingAccounts->count(),
|
||||
'active' => $hostingAccounts->where('status', 'active')->count()
|
||||
+ $orders->where('status', 'active')->count()
|
||||
+ $rcHostingOrders->where('status', 'active')->count()
|
||||
+ $rcServiceOrders->where('status', 'active')->count(),
|
||||
'pending' => $orders->whereIn('status', $pendingStatuses)->count()
|
||||
+ $rcHostingOrders->whereIn('status', $pendingStatuses)->count()
|
||||
+ $rcServiceOrders->whereIn('status', $pendingStatuses)->count(),
|
||||
'plans' => count($nativeForm['packages'] ?? []),
|
||||
];
|
||||
|
||||
return view('hosting.type', [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
@@ -119,6 +132,7 @@ class HostingProductController extends Controller
|
||||
'marketingCategory' => $marketingCategory,
|
||||
'nativeForm' => $nativeForm,
|
||||
'userDomains' => $userDomains,
|
||||
'heroStats' => $heroStats,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -127,11 +141,12 @@ class HostingProductController extends Controller
|
||||
$user = $request->user();
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
|
||||
// New Hosting Orders
|
||||
// In-flight orders only — Active accounts belong under Hosting Accounts
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->inFlight()
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'vpsInstance'])
|
||||
->with(['product', 'vpsInstance', 'hostingAccount.sites'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
@@ -197,7 +212,14 @@ class HostingProductController extends Controller
|
||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||
|
||||
$account->load(['product', 'user', 'node', 'sites']);
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
|
||||
// Heal legacy Sales Centre placeholders (pending-setup.local) that stayed
|
||||
// primary after a real domain was linked as an addon.
|
||||
app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class)->healAccount($account);
|
||||
$account->refresh()->load(['product', 'user', 'node', 'sites']);
|
||||
|
||||
$placeholders = app(\App\Services\Hosting\PlaceholderPrimaryDomainService::class);
|
||||
$maxDomains = $account->maxDomainsLimit();
|
||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||
$mailboxes = $account->loadedMailboxes();
|
||||
$mailboxCount = $mailboxes->count();
|
||||
@@ -205,10 +227,14 @@ class HostingProductController extends Controller
|
||||
|
||||
return view('hosting.account', [
|
||||
'account' => $account,
|
||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||
'accountLabel' => $placeholders->displayLabel($account),
|
||||
'linkedSites' => $account->sites
|
||||
->filter(fn ($site) => ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($site->domain))
|
||||
->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])
|
||||
->values(),
|
||||
'maxDomains' => $maxDomains,
|
||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
||||
'ownedDomains' => $this->getUserDomains($request->user()),
|
||||
'canLinkMoreDomains' => $account->canAddDomain(),
|
||||
'ownedDomains' => collect($this->getUserDomains($request->user())),
|
||||
'emailUsage' => [
|
||||
'mailbox_count' => $mailboxCount,
|
||||
'free_allowance' => $freeEmailAllowance,
|
||||
@@ -632,17 +658,9 @@ class HostingProductController extends Controller
|
||||
return $product->catalogSummary();
|
||||
}
|
||||
|
||||
private function getUserDomains($user): \Illuminate\Support\Collection
|
||||
private function getUserDomains($user): array
|
||||
{
|
||||
return \App\Models\Domain::query()
|
||||
->where('user_id', $user->id)
|
||||
->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']);
|
||||
return app(\App\Services\Domains\LadillDomainsClient::class)
|
||||
->ownedForUser((string) $user->public_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use App\Services\Hosting\HostingAccessResolver;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -100,9 +100,7 @@ class OverviewController extends Controller
|
||||
{
|
||||
$sharedTypes = $this->sharedTypes();
|
||||
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
|
||||
@@ -5,8 +5,8 @@ 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\HostingProduct;
|
||||
use App\Services\Hosting\HostingAccessResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
@@ -49,9 +49,7 @@ class SearchController extends Controller
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
|
||||
|
||||
HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Domains\LadillDomainsClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OwnedDomainsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, LadillDomainsClient $domains): JsonResponse
|
||||
{
|
||||
$exclude = collect($request->query('exclude', []))
|
||||
->map(fn ($d) => strtolower(trim((string) $d)))
|
||||
->filter()
|
||||
->all();
|
||||
|
||||
$owned = collect($domains->ownedForUser((string) ladill_account()->public_id))
|
||||
->reject(fn ($d) => in_array($d, $exclude, true))
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return response()->json(['data' => $owned]);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductLandingController extends Controller
|
||||
{
|
||||
public function show(Request $request): View|RedirectResponse
|
||||
{
|
||||
$landing = config('product_landing', []);
|
||||
$variant = (string) ($landing['landing_variant'] ?? 'default');
|
||||
$dashboardRoute = (string) ($landing['dashboard_route'] ?? 'login');
|
||||
|
||||
if ($variant === 'webmail') {
|
||||
if ($request->session()->has('mb.email')) {
|
||||
return redirect()->route($dashboardRoute);
|
||||
}
|
||||
|
||||
return view('product.landing');
|
||||
}
|
||||
|
||||
if (auth()->check()) {
|
||||
return redirect()->route($dashboardRoute);
|
||||
}
|
||||
|
||||
return view('product.landing');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Billing\BillingClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Lightweight wallet balance for the avatar dropdown widget.
|
||||
*/
|
||||
class WalletBalanceController extends Controller
|
||||
{
|
||||
public function balance(Request $request, BillingClient $billing): JsonResponse
|
||||
{
|
||||
$publicId = (string) $request->user()->public_id;
|
||||
|
||||
$minor = Cache::remember("wallet_balance:{$publicId}", now()->addSeconds(30), function () use ($billing, $publicId) {
|
||||
try {
|
||||
return $billing->balanceMinor($publicId);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
if ($minor === null) {
|
||||
return response()->json(['available' => false]);
|
||||
}
|
||||
|
||||
$currency = (string) config('billing.currency', 'GHS');
|
||||
|
||||
return response()->json([
|
||||
'available' => true,
|
||||
'balance_minor' => $minor,
|
||||
'currency' => $currency,
|
||||
'formatted' => $currency.' '.number_format($minor / 100, 2),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Generic service-to-service auth for internal APIs. Validates the bearer token
|
||||
* against per-consumer keys in config("{namespace}.service_api_keys").
|
||||
*/
|
||||
class AuthenticateService
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $namespace = 'platform'): Response
|
||||
{
|
||||
$token = (string) $request->bearerToken();
|
||||
$caller = null;
|
||||
|
||||
if ($token !== '') {
|
||||
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
|
||||
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
|
||||
$caller = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($caller === null) {
|
||||
return response()->json(['error' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$request->attributes->set('service_caller', $caller);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||
* When the platform session ends, clear this app's local session too.
|
||||
*
|
||||
* Re-checks auth.ladill.com at most once per TTL — not on every request.
|
||||
* Client-side sso-keepalive still pings periodically between checks.
|
||||
*/
|
||||
class EnsurePlatformSession
|
||||
{
|
||||
private const VERIFY_TTL_SECONDS = 90;
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||
if ($authDomain === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
|
||||
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||
if ($cookieHeader === '') {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||
->timeout(3)
|
||||
->connectTimeout(2)
|
||||
->get('https://'.$authDomain.'/sso/ping');
|
||||
} catch (\Throwable) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
$request->session()->put('platform_session_verified_at', time());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function clearAppSession(Request $request): Response
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Injects a branded boot splash (Ladill Mail style) into authenticated HTML
|
||||
* pages. The splash shows once per browser session while the app loads, then
|
||||
* fades out — masking the client-side boot so the app feels instant.
|
||||
*/
|
||||
class InjectBootSplash
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
if (! $request->isMethod('GET') || ! Auth::check()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
if (! str_contains((string) $response->headers->get('Content-Type', ''), 'text/html')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$content = $response->getContent();
|
||||
if (! is_string($content)
|
||||
|| stripos($content, '<body') === false
|
||||
|| str_contains($content, 'id="ladill-boot"')) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$splash = View::make('partials.boot-splash')->render();
|
||||
$content = preg_replace_callback('/<body\b[^>]*>/i', fn ($m) => $m[0].$splash, $content, 1);
|
||||
|
||||
if (is_string($content)) {
|
||||
$response->setContent($content);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -141,12 +141,16 @@ class ProvisionHostingOrderJob implements ShouldQueue
|
||||
]);
|
||||
|
||||
// Create hosting account record
|
||||
$primaryDomain = \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($this->order->domain_name)
|
||||
? null
|
||||
: $this->order->domain_name;
|
||||
|
||||
$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,
|
||||
'primary_domain' => $primaryDomain,
|
||||
'type' => HostingAccount::TYPE_SHARED,
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
|
||||
@@ -164,6 +168,7 @@ class ProvisionHostingOrderJob implements ShouldQueue
|
||||
'warning_count' => 0,
|
||||
'suspended_at' => null,
|
||||
'provisioned_at' => now(),
|
||||
'expires_at' => $this->order->calculateExpiryDate(),
|
||||
'metadata' => [
|
||||
'assigned_duration_months' => $this->order->getCycleLengthInMonths(),
|
||||
'initial_password' => $password,
|
||||
@@ -178,16 +183,18 @@ class ProvisionHostingOrderJob implements ShouldQueue
|
||||
], $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,
|
||||
]);
|
||||
if ($primaryDomain) {
|
||||
HostedSite::create([
|
||||
'hosting_account_id' => $account->id,
|
||||
'domain_id' => $this->order->domain_id,
|
||||
'domain' => $primaryDomain,
|
||||
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
|
||||
'type' => 'primary',
|
||||
'status' => 'active',
|
||||
'php_version' => '8.4',
|
||||
'ssl_enabled' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
$sharedProvider->applyAccountResourceProfile($account, false);
|
||||
|
||||
|
||||
@@ -56,6 +56,16 @@ class ProvisionHostingSslJob implements ShouldQueue
|
||||
|
||||
$site->update(['ssl_status' => 'provisioning']);
|
||||
|
||||
// Surface this site's domain in Ladill Domains' "My Domains" (best-effort,
|
||||
// independent of SSL outcome).
|
||||
if ($account->user?->public_id) {
|
||||
app(\App\Services\Ssl\CentralSslRegistry::class)->registerConnectedDomain(
|
||||
$site->domain,
|
||||
(string) $account->user->public_id,
|
||||
'Hosting: '.$site->domain,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$provider->requestLetsEncryptCertificate($site);
|
||||
|
||||
@@ -66,10 +76,20 @@ class ProvisionHostingSslJob implements ShouldQueue
|
||||
'ssl_expires_at' => now()->addDays(90),
|
||||
]);
|
||||
|
||||
if ($site->domain_id) {
|
||||
\App\Models\Domain::query()->whereKey($site->domain_id)->update([
|
||||
'ssl_status' => 'active',
|
||||
'ssl_expires_at' => $site->ssl_expires_at,
|
||||
]);
|
||||
}
|
||||
|
||||
Log::info('ProvisionHostingSslJob: SSL provisioned successfully', [
|
||||
'site_id' => $this->siteId,
|
||||
'domain' => $site->domain,
|
||||
]);
|
||||
|
||||
// Record the cert in Ladill Domains' central SSL registry (best-effort).
|
||||
app(\App\Services\Ssl\CentralSslRegistry::class)->register($site->domain, $site->ssl_expires_at);
|
||||
} catch (\Throwable $exception) {
|
||||
$message = $exception->getMessage();
|
||||
|
||||
|
||||
@@ -146,6 +146,53 @@ class CustomerHostingOrder extends Model
|
||||
return $query->where('user_id', $userId);
|
||||
}
|
||||
|
||||
/** Orders still awaiting payment, approval, or provisioning — not live accounts. */
|
||||
public function scopeInFlight($query)
|
||||
{
|
||||
return $query->whereIn('status', [
|
||||
self::STATUS_PENDING_PAYMENT,
|
||||
self::STATUS_PENDING_APPROVAL,
|
||||
self::STATUS_APPROVED,
|
||||
self::STATUS_PROVISIONING,
|
||||
]);
|
||||
}
|
||||
|
||||
public static function isPlaceholderDomainName(?string $domain): bool
|
||||
{
|
||||
$domain = strtolower(trim((string) $domain));
|
||||
|
||||
return $domain === '' || $domain === 'pending-setup.local' || str_ends_with($domain, '.ladill.com');
|
||||
}
|
||||
|
||||
/**
|
||||
* Label for UI lists — never surface pending-setup.local when a real
|
||||
* hosting account / domain is available.
|
||||
*/
|
||||
public function displayDomainLabel(): string
|
||||
{
|
||||
if ($this->relationLoaded('hostingAccount') && $this->hostingAccount) {
|
||||
$account = $this->hostingAccount;
|
||||
if (! $account->relationLoaded('sites')) {
|
||||
$account->load('sites');
|
||||
}
|
||||
|
||||
$label = $account->linkedDomainLabel();
|
||||
if (filled($label) && ! static::isPlaceholderDomainName($label)) {
|
||||
return $label;
|
||||
}
|
||||
}
|
||||
|
||||
if (! static::isPlaceholderDomainName($this->domain_name)) {
|
||||
return (string) $this->domain_name;
|
||||
}
|
||||
|
||||
if (filled($this->server_ip)) {
|
||||
return (string) $this->server_ip;
|
||||
}
|
||||
|
||||
return 'Domain pending';
|
||||
}
|
||||
|
||||
public function isPendingApproval(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING_APPROVAL;
|
||||
|
||||
@@ -121,4 +121,17 @@ class Domain extends Model
|
||||
return (string) $this->status === 'verified'
|
||||
&& (string) $this->onboarding_state === self::STATE_ACTIVE;
|
||||
}
|
||||
|
||||
/** True when Ladill should publish zone records (nameserver / managed DNS path). */
|
||||
public function usesManagedDns(): bool
|
||||
{
|
||||
if ((string) $this->dns_mode === 'managed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array((string) $this->onboarding_mode, [
|
||||
self::MODE_NS_AUTO,
|
||||
self::MODE_RESELLER_AUTO,
|
||||
], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Services\Hosting\PlaceholderPrimaryDomainService;
|
||||
|
||||
class HostingAccount extends Model
|
||||
{
|
||||
@@ -139,7 +140,15 @@ class HostingAccount extends Model
|
||||
public function linkedDomainLabel(): ?string
|
||||
{
|
||||
if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) {
|
||||
$domains = $this->sites->pluck('domain')->filter()->unique()->values();
|
||||
$domains = $this->sites
|
||||
->pluck('domain')
|
||||
->filter(fn ($domain) => filled($domain) && ! PlaceholderPrimaryDomainService::isPlaceholderDomain((string) $domain))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($domains->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($domains->count() === 1) {
|
||||
return (string) $domains->first();
|
||||
@@ -152,7 +161,11 @@ class HostingAccount extends Model
|
||||
: $preview;
|
||||
}
|
||||
|
||||
return filled($this->primary_domain) ? (string) $this->primary_domain : null;
|
||||
if (filled($this->primary_domain) && ! PlaceholderPrimaryDomainService::isPlaceholderDomain($this->primary_domain)) {
|
||||
return (string) $this->primary_domain;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function databases(): HasMany
|
||||
@@ -311,6 +324,61 @@ class HostingAccount extends Model
|
||||
return max($included, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Domain slot limit from resource limits / product. -1 means unlimited.
|
||||
*/
|
||||
public function maxDomainsLimit(): int
|
||||
{
|
||||
$limit = data_get($this->resource_limits, 'max_domains');
|
||||
if ($limit === null) {
|
||||
$limit = $this->product?->max_domains;
|
||||
}
|
||||
|
||||
if ($limit === null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return (int) $limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subdomain slot limit: max_domains × 5. Unlimited domains → unlimited subdomains.
|
||||
*/
|
||||
public function maxSubdomainsLimit(): int
|
||||
{
|
||||
$maxDomains = $this->maxDomainsLimit();
|
||||
|
||||
return $maxDomains === -1 ? -1 : max(0, $maxDomains) * 5;
|
||||
}
|
||||
|
||||
public function domainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->whereIn('type', ['primary', 'addon'])
|
||||
->count();
|
||||
}
|
||||
|
||||
public function subdomainSitesCount(): int
|
||||
{
|
||||
return $this->sites()
|
||||
->where('type', 'subdomain')
|
||||
->count();
|
||||
}
|
||||
|
||||
public function canAddDomain(): bool
|
||||
{
|
||||
$limit = $this->maxDomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->domainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function canAddSubdomain(): bool
|
||||
{
|
||||
$limit = $this->maxSubdomainsLimit();
|
||||
|
||||
return $limit === -1 || $this->subdomainSitesCount() < $limit;
|
||||
}
|
||||
|
||||
public function paidMailboxCount(): int
|
||||
{
|
||||
if (! static::tracksLocalMailboxes()) {
|
||||
|
||||
@@ -21,7 +21,7 @@ class HostingResourceWarningNotification extends Notification implements ShouldQ
|
||||
|
||||
public function via($notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toMail($notifiable): MailMessage
|
||||
|
||||
@@ -151,6 +151,40 @@ class PowerDnsClient
|
||||
return ['status' => 'updated', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete specific name+type rrsets from an existing zone (best-effort).
|
||||
*
|
||||
* @param string $zoneName
|
||||
* @param array<int, array{name: string, type?: string}> $records
|
||||
*/
|
||||
public function deleteRecords(string $zoneName, array $records): array
|
||||
{
|
||||
if ($records === []) {
|
||||
return ['status' => 'noop'];
|
||||
}
|
||||
|
||||
$zoneName = rtrim($zoneName, '.').'.';
|
||||
|
||||
$rrsets = array_map(function (array $record): array {
|
||||
return [
|
||||
'name' => rtrim($record['name'], '.').'.',
|
||||
'type' => strtoupper($record['type'] ?? 'A'),
|
||||
'changetype' => 'DELETE',
|
||||
'records' => [],
|
||||
];
|
||||
}, $records);
|
||||
|
||||
$response = $this->httpClient()->patch("servers/{$this->server()}/zones/{$zoneName}", [
|
||||
'rrsets' => $rrsets,
|
||||
]);
|
||||
|
||||
if ($response->failed() && $response->status() !== 404) {
|
||||
$response->throw();
|
||||
}
|
||||
|
||||
return ['status' => 'deleted', 'count' => count($rrsets)];
|
||||
}
|
||||
|
||||
public function deleteZone(Domain $domain): void
|
||||
{
|
||||
$zoneName = $this->zoneName($domain);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services\Domain;
|
||||
|
||||
use App\Jobs\MailProvisioningJob;
|
||||
use App\Jobs\ProvisionDomainSlaveZoneJob;
|
||||
use App\Jobs\ProvisionDomainZoneJob;
|
||||
use App\Jobs\SslProvisioningJob;
|
||||
use App\Models\Domain;
|
||||
@@ -12,6 +13,7 @@ use App\Models\DomainNsCheck;
|
||||
use App\Notifications\DomainVerifiedNotification;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DomainVerificationService
|
||||
@@ -265,7 +267,26 @@ class DomainVerificationService
|
||||
|
||||
public function reprovision(Domain $domain): void
|
||||
{
|
||||
$this->dispatchProvisioningChain($domain->id);
|
||||
$domain = $domain->fresh() ?? $domain;
|
||||
$this->ensureDkimKeys($domain);
|
||||
|
||||
// Create the authoritative zone immediately when possible. Async-only
|
||||
// provisioning left Ladill NS delegated with REFUSED answers (SERVFAIL
|
||||
// for Let's Encrypt) whenever the queue lagged or failed.
|
||||
try {
|
||||
$this->pdns->ensureZone($domain->fresh());
|
||||
ProvisionDomainSlaveZoneJob::dispatch($domain->id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Immediate PDNS ensureZone failed; queueing retry', [
|
||||
'domain_id' => $domain->id,
|
||||
'host' => $domain->host,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
ProvisionDomainZoneJob::dispatch($domain->id);
|
||||
}
|
||||
|
||||
MailProvisioningJob::dispatch($domain->id);
|
||||
SslProvisioningJob::dispatch($domain->id);
|
||||
}
|
||||
|
||||
private function dispatchProvisioningChain(int $domainId): void
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Domains;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/** Read-only client for domains purchased on Ladill (storefront + platform registry). */
|
||||
class LadillDomainsClient
|
||||
{
|
||||
/** @return list<string> Active domain names owned by the account (lowercase). */
|
||||
public function ownedForUser(string $publicId): array
|
||||
{
|
||||
[$storefront, $platform] = $this->fetchOwnedDomainsInParallel($publicId);
|
||||
|
||||
return collect()
|
||||
->merge($storefront)
|
||||
->merge($platform)
|
||||
->map(fn ($d) => strtolower(trim((string) $d)))
|
||||
->filter()
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: list<string>, 1: list<string>}
|
||||
*/
|
||||
private function fetchOwnedDomainsInParallel(string $publicId): array
|
||||
{
|
||||
$storefront = [];
|
||||
$platform = [];
|
||||
|
||||
try {
|
||||
$responses = Http::pool(function ($pool) use ($publicId) {
|
||||
$requests = [];
|
||||
$storefrontKey = (string) (config('domains.api_key') ?? '');
|
||||
if ($storefrontKey !== '') {
|
||||
$requests['storefront'] = $pool->as('storefront')
|
||||
->withToken($storefrontKey)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get(rtrim((string) config('domains.api_url'), '/').'/owned-domains', [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
}
|
||||
$platformKey = (string) (config('domains.platform_api_key') ?? '');
|
||||
if ($platformKey !== '') {
|
||||
$requests['platform'] = $pool->as('platform')
|
||||
->withToken($platformKey)
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->get(rtrim((string) config('domains.platform_api_url'), '/').'/owned-domains', [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
}
|
||||
|
||||
return $requests;
|
||||
});
|
||||
|
||||
foreach ($responses as $name => $response) {
|
||||
if (! $response instanceof Response || ! $response->successful()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = (array) $response->json('data', []);
|
||||
if ($name === 'storefront') {
|
||||
$storefront = $data;
|
||||
} elseif ($name === 'platform') {
|
||||
$platform = $data;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('LadillDomainsClient: owned domains lookup failed', [
|
||||
'user' => $publicId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return [$storefront, $platform];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting;
|
||||
|
||||
use App\Jobs\ProvisionHostingAccountJob;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AdminHostingAssignmentService
|
||||
{
|
||||
public function __construct(
|
||||
private NodeCapacityService $nodeCapacity,
|
||||
private HostingResourcePolicyService $resourcePolicy,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function assign(User $owner, array $input): array
|
||||
{
|
||||
$product = HostingProduct::query()->findOrFail((int) $input['hosting_product_id']);
|
||||
|
||||
if (! $product->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'hosting_product_id' => 'Selected hosting product is not active.',
|
||||
]);
|
||||
}
|
||||
|
||||
$username = isset($input['username']) && $input['username'] !== ''
|
||||
? (string) $input['username']
|
||||
: $this->generateUsername($owner);
|
||||
|
||||
if (HostingAccount::query()->where('username', $username)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'username' => 'This username is already taken.',
|
||||
]);
|
||||
}
|
||||
|
||||
$primaryDomain = isset($input['primary_domain']) && $input['primary_domain'] !== ''
|
||||
? strtolower(trim((string) $input['primary_domain']))
|
||||
: null;
|
||||
|
||||
if ($primaryDomain !== null && PlaceholderPrimaryDomainService::isPlaceholderDomain($primaryDomain)) {
|
||||
$primaryDomain = null;
|
||||
}
|
||||
|
||||
$accountType = match ($product->type) {
|
||||
'single_domain', 'multi_domain', 'wordpress' => HostingAccount::TYPE_SHARED,
|
||||
'vps' => HostingAccount::TYPE_VPS,
|
||||
'dedicated' => HostingAccount::TYPE_DEDICATED,
|
||||
'email_standalone' => HostingAccount::TYPE_SHARED,
|
||||
default => HostingAccount::TYPE_SHARED,
|
||||
};
|
||||
|
||||
$node = null;
|
||||
$nodeId = null;
|
||||
if ($accountType === HostingAccount::TYPE_SHARED) {
|
||||
$node = $this->nodeCapacity->findAvailableNodeForProduct($product);
|
||||
$nodeId = $node?->id;
|
||||
}
|
||||
|
||||
$resourceLimits = $this->resourcePolicy->defaultLimitsForProduct($product);
|
||||
$durationMonths = (int) $input['duration_months'];
|
||||
|
||||
$hostingAccount = HostingAccount::create([
|
||||
'user_id' => $owner->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'hosting_node_id' => $nodeId,
|
||||
'username' => $username,
|
||||
'primary_domain' => $primaryDomain,
|
||||
'type' => $accountType,
|
||||
'status' => HostingAccount::STATUS_PENDING,
|
||||
'allocated_disk_gb' => (int) ($product->disk_gb ?? 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,
|
||||
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
||||
'resource_limits' => array_merge([
|
||||
'disk_gb' => (int) ($product->disk_gb ?? 0),
|
||||
'max_domains' => $product->max_domains,
|
||||
'max_databases' => $product->max_databases,
|
||||
], $resourceLimits),
|
||||
'metadata' => [
|
||||
'assigned_by_admin' => $input['assigned_by_admin'] ?? null,
|
||||
'assigned_at' => now()->toISOString(),
|
||||
'assigned_duration_months' => $durationMonths,
|
||||
'notes' => $input['notes'] ?? null,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($node) {
|
||||
ProvisionHostingAccountJob::dispatch($hostingAccount->id);
|
||||
|
||||
return [
|
||||
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
||||
'provisioning' => true,
|
||||
'message' => "Hosting product '{$product->name}' assigned. Provisioning is in progress.",
|
||||
];
|
||||
}
|
||||
|
||||
$hostingAccount->update([
|
||||
'status' => HostingAccount::STATUS_ACTIVE,
|
||||
'provisioned_at' => now(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'account' => $this->serializeAccount($hostingAccount->fresh(['product'])),
|
||||
'provisioning' => false,
|
||||
'message' => "Hosting product '{$product->name}' assigned.",
|
||||
];
|
||||
}
|
||||
|
||||
public function updateDuration(HostingAccount $account, int $durationMonths, ?int $adminId = null): array
|
||||
{
|
||||
$metadata = (array) ($account->metadata ?? []);
|
||||
$metadata['assigned_duration_months'] = $durationMonths;
|
||||
$metadata['duration_updated_by_admin'] = $adminId;
|
||||
$metadata['duration_updated_at'] = now()->toISOString();
|
||||
|
||||
$account->update([
|
||||
'expires_at' => $this->expiresAtFromDuration($durationMonths),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return $this->serializeAccount($account->fresh(['product']));
|
||||
}
|
||||
|
||||
public function renew(HostingAccount $account, ?int $adminId = null): array
|
||||
{
|
||||
if (! $account->canBeRenewed()) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => 'This hosting account does not have a configured renewal duration.',
|
||||
]);
|
||||
}
|
||||
|
||||
$months = $account->assignedDurationMonths();
|
||||
$account->renew($months, [
|
||||
'renewed_by_admin' => $adminId,
|
||||
'renewed_at' => now()->toISOString(),
|
||||
]);
|
||||
|
||||
return $this->serializeAccount($account->fresh(['product']));
|
||||
}
|
||||
|
||||
public function unsuspend(HostingAccount $account, HostingResourcePolicyService $resourcePolicy): void
|
||||
{
|
||||
if (
|
||||
$account->status !== HostingAccount::STATUS_SUSPENDED
|
||||
&& $account->resource_status !== HostingAccount::RESOURCE_STATUS_SUSPENDED
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => 'This hosting account is not suspended.',
|
||||
]);
|
||||
}
|
||||
|
||||
$resourcePolicy->unsuspendAccount($account, 'Manual unsuspension by admin.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function serializeAccount(HostingAccount $account): array
|
||||
{
|
||||
$product = $account->product;
|
||||
|
||||
return [
|
||||
'id' => $account->id,
|
||||
'username' => $account->username,
|
||||
'primary_domain' => $account->primary_domain,
|
||||
'type' => $account->type,
|
||||
'status' => $account->status,
|
||||
'resource_status' => $account->resource_status,
|
||||
'expires_at' => $account->expires_at?->toIso8601String(),
|
||||
'provisioned_at' => $account->provisioned_at?->toIso8601String(),
|
||||
'metadata' => (array) ($account->metadata ?? []),
|
||||
'product' => $product ? [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->type,
|
||||
'slug' => $product->slug,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listProducts(): array
|
||||
{
|
||||
return HostingProduct::query()
|
||||
->where('is_active', true)
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn (HostingProduct $product) => [
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->type,
|
||||
'slug' => $product->slug,
|
||||
'display_currency' => $product->display_currency,
|
||||
'display_price_monthly' => $product->display_price_monthly,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function generateUsername(User $user): string
|
||||
{
|
||||
$baseUsername = strtolower(str_replace([' ', '.', '_'], '', $user->name));
|
||||
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
||||
|
||||
if (strlen($baseUsername) < 3) {
|
||||
$baseUsername = strtolower(substr($user->email, 0, str_contains($user->email, '@') ? strpos($user->email, '@') : strlen($user->email)));
|
||||
$baseUsername = preg_replace('/[^a-z0-9]/', '', $baseUsername);
|
||||
}
|
||||
|
||||
if (strlen($baseUsername) < 3) {
|
||||
$baseUsername = 'user'.$user->id;
|
||||
}
|
||||
|
||||
$username = substr($baseUsername, 0, 16);
|
||||
$counter = 1;
|
||||
$originalUsername = $username;
|
||||
|
||||
while (HostingAccount::query()->where('username', $username)->exists()) {
|
||||
$suffix = (string) $counter;
|
||||
$maxBaseLength = 16 - strlen($suffix);
|
||||
$username = substr($originalUsername, 0, $maxBaseLength).$suffix;
|
||||
$counter++;
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
private function expiresAtFromDuration(int $durationMonths): CarbonInterface
|
||||
{
|
||||
return now()->addMonthsNoOverflow($durationMonths);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,11 @@ class BrowserTerminalSessionManager
|
||||
{
|
||||
private const IDLE_TIMEOUT_SECONDS = 120;
|
||||
|
||||
// How long createSession() waits for the worker to cold-boot the framework
|
||||
// and open the shell before returning. Generous so a slow/loaded boot isn't
|
||||
// mistaken for a failure; the wait returns early once the status is known.
|
||||
private const STARTUP_TIMEOUT_SECONDS = 8.0;
|
||||
|
||||
public function createSession(HostingAccount $account, int $userId, string $relativePath = '/public_html', int $cols = 120, int $rows = 30): array
|
||||
{
|
||||
$sessionId = (string) Str::uuid();
|
||||
@@ -334,7 +339,9 @@ class BrowserTerminalSessionManager
|
||||
|
||||
private function waitForWorkerStartup(string $sessionId, array $metadata): array
|
||||
{
|
||||
$deadline = microtime(true) + 2.5;
|
||||
// The loop returns the moment status leaves "starting" (running OR a
|
||||
// genuine error the worker recorded), so a fast boot still returns fast.
|
||||
$deadline = microtime(true) + self::STARTUP_TIMEOUT_SECONDS;
|
||||
|
||||
while (microtime(true) < $deadline) {
|
||||
usleep(100000);
|
||||
@@ -346,28 +353,11 @@ class BrowserTerminalSessionManager
|
||||
}
|
||||
}
|
||||
|
||||
$metadata['status'] = 'failed';
|
||||
$metadata['exit_code'] = 1;
|
||||
$metadata['closed_at'] = now()->toIso8601String();
|
||||
$metadata['error'] = $this->workerBootstrapError($sessionId);
|
||||
$this->writeMetadata($sessionId, $metadata);
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
private function workerBootstrapError(string $sessionId): string
|
||||
{
|
||||
$output = trim((string) @file_get_contents($this->outputLogPath($sessionId)));
|
||||
|
||||
if ($output !== '') {
|
||||
$lines = preg_split("/\r\n|\n|\r/", $output) ?: [];
|
||||
$tail = trim((string) end($lines));
|
||||
|
||||
if ($tail !== '') {
|
||||
return 'Terminal worker failed to start: '.$tail;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Terminal worker failed to start.';
|
||||
// Still starting after the wait: do NOT fabricate a failure — that also
|
||||
// clobbered workers that were about to come up, which is exactly what made
|
||||
// healthy terminals report "failed to start". Return current metadata; the
|
||||
// browser keeps polling and the worker writes a real error itself if boot
|
||||
// ultimately fails.
|
||||
return $this->readWorkerMetadata($sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting;
|
||||
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\User;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Resolves which hosting accounts a user can access as a developer (shared with
|
||||
* them). The monolith is the source of truth; this consolidates the previously
|
||||
* inline HostingAccountMember lookups into one place so authz can move to the
|
||||
* central identity API.
|
||||
*
|
||||
* Shadow mode (config hosting.central_dev_access = false, default): local
|
||||
* hosting_account_members stays authoritative; central is fetched and any
|
||||
* divergence is logged. When flipped to true, central becomes authoritative
|
||||
* with a fail-open fallback to local if the API is unreachable.
|
||||
*/
|
||||
class HostingAccessResolver
|
||||
{
|
||||
public function __construct(private IdentityClient $identity) {}
|
||||
|
||||
/** @return Collection<int, int> hosting account ids shared with the user */
|
||||
public function sharedAccountIds(User $user): Collection
|
||||
{
|
||||
$local = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$central = $this->centralAccountIds($user);
|
||||
|
||||
if ($central !== null && $central->sort()->values()->all() !== $local->sort()->values()->all()) {
|
||||
Log::warning('Hosting dev-access parity mismatch', [
|
||||
'user_id' => $user->id,
|
||||
'local' => $local->values()->all(),
|
||||
'central' => $central->values()->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (config('hosting.central_dev_access', false)) {
|
||||
return $central ?? $local; // fail-open to local if central unreachable
|
||||
}
|
||||
|
||||
return $local;
|
||||
}
|
||||
|
||||
/** @return Collection<int, int>|null null = central unreachable (fall back to local) */
|
||||
private function centralAccountIds(User $user): ?Collection
|
||||
{
|
||||
if (! $user->public_id) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Cache::remember(
|
||||
"hosting_dev_access:{$user->id}",
|
||||
now()->addSeconds(60),
|
||||
function () use ($user): ?Collection {
|
||||
try {
|
||||
$grants = $this->identity->hostingDeveloperAccess((string) $user->public_id);
|
||||
$usernames = array_values(array_filter(array_map(
|
||||
fn ($g) => $g['username'] ?? null,
|
||||
$grants,
|
||||
)));
|
||||
|
||||
if ($usernames === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return HostingAccount::query()
|
||||
->whereIn('username', $usernames)
|
||||
->pluck('id');
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Central hosting dev-access fetch failed', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting;
|
||||
|
||||
use App\Models\HostedSite;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\Providers\SharedNodeProvider;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Sales Centre used to stamp pending-setup.local as primary_domain (and sometimes a
|
||||
* primary HostedSite). Link Domain historically only created addons, so the
|
||||
* placeholder kept hijacking the account label. This service promotes a real
|
||||
* linked domain and retires placeholder primaries.
|
||||
*/
|
||||
class PlaceholderPrimaryDomainService
|
||||
{
|
||||
public function __construct(
|
||||
private SharedNodeProvider $provider,
|
||||
) {}
|
||||
|
||||
public static function isPlaceholderDomain(?string $domain): bool
|
||||
{
|
||||
$domain = strtolower(trim((string) $domain));
|
||||
|
||||
if ($domain === '' || $domain === 'pending-setup.local') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Temporary nginx host used when an account is provisioned without a domain.
|
||||
return str_ends_with($domain, '.ladill.com');
|
||||
}
|
||||
|
||||
public function accountNeedsPromotion(HostingAccount $account): bool
|
||||
{
|
||||
$account->loadMissing('sites');
|
||||
|
||||
$primarySites = $account->sites->where('type', 'primary')->values();
|
||||
$hasPlaceholderPrimarySite = $primarySites->contains(
|
||||
fn (HostedSite $site): bool => static::isPlaceholderDomain($site->domain)
|
||||
);
|
||||
$hasRealPrimarySite = $primarySites->contains(
|
||||
fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain)
|
||||
);
|
||||
|
||||
// Never steal primary from an account that already has a real primary site.
|
||||
if ($hasRealPrimarySite) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Placeholder primary_domain (incl. null/empty) or only placeholder primary sites.
|
||||
if (static::isPlaceholderDomain($account->primary_domain) || $hasPlaceholderPrimarySite) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a newly linked host should be stored as the account primary site.
|
||||
*/
|
||||
public function shouldBecomePrimary(HostingAccount $account, string $domainHost): bool
|
||||
{
|
||||
if ($this->accountNeedsPromotion($account)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$primary = strtolower(trim((string) $account->primary_domain));
|
||||
|
||||
return $primary !== ''
|
||||
&& $primary === strtolower(trim($domainHost))
|
||||
&& ! $account->sites->contains(
|
||||
fn (HostedSite $site): bool => $site->type === 'primary'
|
||||
&& ! static::isPlaceholderDomain($site->domain)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer a real hosted site over a placeholder primary_domain for UI labels.
|
||||
*/
|
||||
public function displayLabel(HostingAccount $account): string
|
||||
{
|
||||
$account->loadMissing('sites');
|
||||
|
||||
$real = $account->sites
|
||||
->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain))
|
||||
->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->domain])
|
||||
->values();
|
||||
|
||||
if ($real->isNotEmpty()) {
|
||||
if ($real->count() === 1) {
|
||||
return (string) $real->first()->domain;
|
||||
}
|
||||
|
||||
$preview = $real->take(2)->pluck('domain')->implode(', ');
|
||||
|
||||
return $real->count() > 2
|
||||
? "{$preview} +".($real->count() - 2)
|
||||
: $preview;
|
||||
}
|
||||
|
||||
if (! static::isPlaceholderDomain($account->primary_domain)) {
|
||||
return (string) $account->primary_domain;
|
||||
}
|
||||
|
||||
return (string) $account->username;
|
||||
}
|
||||
|
||||
public function promoteSite(HostingAccount $account, HostedSite $site): void
|
||||
{
|
||||
if (static::isPlaceholderDomain($site->domain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$account->loadMissing(['sites', 'node']);
|
||||
|
||||
$site->update(['type' => 'primary']);
|
||||
|
||||
$account->update([
|
||||
'primary_domain' => $site->domain,
|
||||
]);
|
||||
|
||||
foreach ($account->sites as $other) {
|
||||
if ((int) $other->id === (int) $site->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($other->type === 'primary' && static::isPlaceholderDomain($other->domain)) {
|
||||
$this->retirePlaceholderSite($account, $other);
|
||||
} elseif ($other->type === 'primary') {
|
||||
$other->update(['type' => 'addon']);
|
||||
}
|
||||
}
|
||||
|
||||
$account->unsetRelation('sites');
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal accounts that already have a real linked domain but still carry a
|
||||
* placeholder primary_domain / primary HostedSite.
|
||||
*/
|
||||
public function healAccount(HostingAccount $account): bool
|
||||
{
|
||||
if (! $this->accountNeedsPromotion($account)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$account->loadMissing(['sites', 'node']);
|
||||
|
||||
$candidate = $account->sites
|
||||
->filter(fn (HostedSite $site): bool => ! static::isPlaceholderDomain($site->domain)
|
||||
&& in_array($site->type, ['primary', 'addon'], true))
|
||||
->sortBy(fn (HostedSite $site): array => [$site->type !== 'primary', $site->id])
|
||||
->first();
|
||||
|
||||
if (! $candidate) {
|
||||
// No real site yet — just clear a stale placeholder label.
|
||||
if (static::isPlaceholderDomain($account->primary_domain) && filled($account->primary_domain)) {
|
||||
$account->update(['primary_domain' => null]);
|
||||
|
||||
foreach ($account->sites->where('type', 'primary') as $placeholder) {
|
||||
if (static::isPlaceholderDomain($placeholder->domain)) {
|
||||
$this->retirePlaceholderSite($account, $placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->promoteSite($account, $candidate);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function retirePlaceholderSite(HostingAccount $account, HostedSite $site): void
|
||||
{
|
||||
try {
|
||||
if ($account->node) {
|
||||
$this->provider->removeSite($site);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('PlaceholderPrimaryDomainService: best-effort nginx cleanup failed', [
|
||||
'site_id' => $site->id,
|
||||
'domain' => $site->domain,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
$site->delete();
|
||||
}
|
||||
}
|
||||
@@ -909,16 +909,7 @@ NGINX;
|
||||
$domain = $site->domain;
|
||||
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
|
||||
|
||||
// Create document root
|
||||
$quotedDocRoot = escapeshellarg($docRoot);
|
||||
$this->ensureAdministrativeResultSucceeded(
|
||||
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
|
||||
"Failed to create document root for {$domain}"
|
||||
);
|
||||
$this->ensureAdministrativeResultSucceeded(
|
||||
$this->runLocalAdminOperationOrRemote($ssh, 'chown', ["{$username}:{$username}", $docRoot], "chown {$username}:{$username} {$quotedDocRoot}"),
|
||||
"Failed to assign document root ownership for {$domain}"
|
||||
);
|
||||
$this->ensureSiteDocumentRoot($ssh, $username, $docRoot);
|
||||
$this->hardenAccountFilesystem($account, [$docRoot]);
|
||||
|
||||
$this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [
|
||||
@@ -931,6 +922,24 @@ NGINX;
|
||||
];
|
||||
}
|
||||
|
||||
private function ensureSiteDocumentRoot(mixed $ssh, string $username, string $docRoot): void
|
||||
{
|
||||
$quotedDocRoot = escapeshellarg($docRoot);
|
||||
$this->ensureAdministrativeResultSucceeded(
|
||||
$this->runLocalAdminOperationOrRemote($ssh, 'mkdir', [$docRoot], "mkdir -p {$quotedDocRoot}"),
|
||||
"Failed to create document root at {$docRoot}"
|
||||
);
|
||||
$this->ensureAdministrativeResultSucceeded(
|
||||
$this->runLocalAdminOperationOrRemote(
|
||||
$ssh,
|
||||
'run-cmd',
|
||||
["chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"],
|
||||
"chown {$username}:" . self::WEB_SERVER_GROUP . " {$quotedDocRoot} && chmod 2750 {$quotedDocRoot}"
|
||||
),
|
||||
"Failed to set document root ownership at {$docRoot}"
|
||||
);
|
||||
}
|
||||
|
||||
public function hardenAccountFilesystem(HostingAccount $account, array $additionalDocumentRoots = []): void
|
||||
{
|
||||
$node = $account->node;
|
||||
@@ -1002,6 +1011,7 @@ NGINX;
|
||||
$domain = $site->domain;
|
||||
$email = $account->user?->email ?: 'admin@' . $domain;
|
||||
$docRoot = $site->document_root ?: "/home/{$account->username}/public_html/{$domain}";
|
||||
$this->ensureSiteDocumentRoot($ssh, $account->username, $docRoot);
|
||||
$hadCertificate = $this->siteHasCertificate($ssh, $domain);
|
||||
|
||||
Log::info("SSL: Starting certificate request", [
|
||||
@@ -1022,22 +1032,28 @@ NGINX;
|
||||
]);
|
||||
}
|
||||
|
||||
// Build domain list - include www if we manage DNS or if it resolves
|
||||
// Build domain list — include www only for apex sites when it exists.
|
||||
// Subdomains like data.example.com must not request www.data.example.com
|
||||
// (NXDOMAIN), or the whole certificate fails.
|
||||
$domainArgs = ' -d ' . escapeshellarg($domain);
|
||||
$wwwDomain = "www.{$domain}";
|
||||
$domainModel = $site->domain_id ? $site->domain()->first() : null;
|
||||
$dnsIsManaged = $domainModel && $domainModel->dns_mode === 'managed';
|
||||
|
||||
if ($dnsIsManaged) {
|
||||
// We manage DNS, so www record exists - always include it
|
||||
$dnsIsManaged = $domainModel && (
|
||||
(string) $domainModel->dns_mode === 'managed'
|
||||
|| (method_exists($domainModel, 'usesManagedDns') && $domainModel->usesManagedDns())
|
||||
);
|
||||
$isSubdomainSite = ($site->type === 'subdomain') || str_starts_with(strtolower((string) $site->type), 'sub');
|
||||
|
||||
if ($isSubdomainSite) {
|
||||
Log::info('SSL: Skipping www alias for subdomain site', ['domain' => $domain]);
|
||||
} elseif ($dnsIsManaged) {
|
||||
$domainArgs .= ' -d ' . escapeshellarg($wwwDomain);
|
||||
Log::info("SSL: Including www subdomain (managed DNS)", ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
Log::info('SSL: Including www subdomain (managed DNS)', ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
} elseif ($this->domainResolvesToServer($wwwDomain)) {
|
||||
// Manual DNS - only include www if it resolves
|
||||
$domainArgs .= ' -d ' . escapeshellarg($wwwDomain);
|
||||
Log::info("SSL: Including www subdomain (resolves)", ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
Log::info('SSL: Including www subdomain (resolves)', ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
} else {
|
||||
Log::info("SSL: Skipping www subdomain (manual DNS, does not resolve)", ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
Log::info('SSL: Skipping www subdomain (manual DNS, does not resolve)', ['domain' => $domain, 'www' => $wwwDomain]);
|
||||
}
|
||||
|
||||
$result = $this->runLocalAdminOperationOrRemote(
|
||||
@@ -1677,7 +1693,7 @@ LIMITS;
|
||||
'runtime_port' => $port,
|
||||
'runtime_type' => $appType,
|
||||
]),
|
||||
]);
|
||||
])->save();
|
||||
|
||||
try {
|
||||
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
|
||||
@@ -2451,14 +2467,7 @@ SERVICE;
|
||||
NGINX;
|
||||
|
||||
if ($proxyPort !== null) {
|
||||
$httpBody = <<<NGINX
|
||||
access_log /home/{$username}/logs/{$domain}-access.log;
|
||||
error_log /home/{$username}/logs/{$domain}-error.log;
|
||||
|
||||
{$acmeLocation}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{$proxyPort};
|
||||
$proxyHeaders = <<<NGINX
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
@@ -2469,6 +2478,27 @@ NGINX;
|
||||
proxy_cache_bypass \$http_upgrade;
|
||||
proxy_read_timeout 300;
|
||||
proxy_connect_timeout 300;
|
||||
NGINX;
|
||||
$apiPort = data_get($site->app_config, 'api_runtime_port');
|
||||
$apiLocation = is_numeric($apiPort)
|
||||
? <<<NGINX
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:{$apiPort};
|
||||
{$proxyHeaders}
|
||||
}
|
||||
NGINX
|
||||
: '';
|
||||
$httpBody = <<<NGINX
|
||||
access_log /home/{$username}/logs/{$domain}-access.log;
|
||||
error_log /home/{$username}/logs/{$domain}-error.log;
|
||||
|
||||
{$acmeLocation}
|
||||
{$apiLocation}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:{$proxyPort};
|
||||
{$proxyHeaders}
|
||||
}
|
||||
|
||||
client_max_body_size 64M;
|
||||
|
||||
@@ -43,6 +43,23 @@ class IdentityClient
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosting accounts (by cPanel username) the user can access as a developer,
|
||||
* from the central monolith (source of truth for hosting developer access).
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function hostingDeveloperAccess(string $publicId): array
|
||||
{
|
||||
$response = $this->request()->get($this->url('/identity/hosting/developer-access'), [
|
||||
'user' => $publicId,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return (array) $response->json('data', []);
|
||||
}
|
||||
|
||||
private function request()
|
||||
{
|
||||
return Http::withToken((string) config('identity.api_key'))
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Platform;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
class PlatformUserResolver
|
||||
{
|
||||
public function resolveOrCreate(string $publicId, string $email = '', string $name = ''): ?User
|
||||
{
|
||||
$publicId = trim($publicId);
|
||||
if ($publicId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::query()->where('public_id', $publicId)->first();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
if ($email === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return User::query()->create([
|
||||
'public_id' => $publicId,
|
||||
'email' => $email,
|
||||
'name' => $name !== '' ? $name : $email,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ssl;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Reports certificates this app issued (per hosting node) to Ladill Domains'
|
||||
* central SSL registry, so Domains is the single source of truth for every
|
||||
* platform certificate. Best-effort and non-blocking — issuance stays local.
|
||||
*/
|
||||
class CentralSslRegistry
|
||||
{
|
||||
public function register(string $host, ?\DateTimeInterface $expiresAt = null): void
|
||||
{
|
||||
$url = rtrim((string) config('domains.api_url'), '/');
|
||||
$key = (string) config('domains.api_key');
|
||||
if ($url === '' || $key === '' || $host === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::withToken($key)->acceptJson()->timeout(10)
|
||||
->post($url.'/ssl/register', array_filter([
|
||||
'host' => $host,
|
||||
'status' => 'active',
|
||||
'expires_at' => $expiresAt?->format(DATE_ATOM),
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('CentralSslRegistry: register skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Surface a hosted-site domain in Ladill Domains' "My Domains" (best-effort). */
|
||||
public function registerConnectedDomain(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
|
||||
{
|
||||
$url = rtrim((string) config('domains.api_url'), '/');
|
||||
$key = (string) config('domains.api_key');
|
||||
if ($url === '' || $key === '' || $host === '' || $ownerPublicId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Http::withToken($key)->acceptJson()->timeout(10)
|
||||
->post($url.'/connected-domains', array_filter([
|
||||
'host' => $host,
|
||||
'owner_public_id' => $ownerPublicId,
|
||||
'label' => $label,
|
||||
'link_url' => $linkUrl,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('CentralSslRegistry: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
class UserProfileMenu
|
||||
{
|
||||
/**
|
||||
* Standard signed-in profile links for Ladill product apps.
|
||||
* Not used by Ladill Mail (mail.ladill.com) — that app keeps a mailbox-specific menu.
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public static function items(?Authenticatable $user = null): array
|
||||
{
|
||||
$user ??= auth()->user();
|
||||
|
||||
if (! $user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
|
||||
foreach ([
|
||||
['label' => 'Home', 'path' => '', 'host' => 'home'],
|
||||
['label' => 'Profile', 'path' => 'profile'],
|
||||
['label' => 'Account Settings', 'path' => 'account-settings'],
|
||||
['label' => 'Dashboard', 'path' => 'dashboard'],
|
||||
['label' => 'Billing', 'path' => 'billing'],
|
||||
] as $link) {
|
||||
$href = self::platformUrl($link['host'] ?? 'account', $link['path']);
|
||||
|
||||
if (self::isCurrentMenuLink($link, $href)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => $link['label'],
|
||||
'href' => $href,
|
||||
];
|
||||
}
|
||||
|
||||
if (Route::has((string) config('billing.wallet_balance_route', 'wallet.balance'))) {
|
||||
$items[] = ['type' => 'wallet'];
|
||||
}
|
||||
|
||||
if (self::userIsAdmin($user)) {
|
||||
$adminHref = self::platformUrl('account', 'admin');
|
||||
|
||||
if (! self::isCurrentMenuLink(['path' => 'admin', 'host' => 'account'], $adminHref)) {
|
||||
$items[] = [
|
||||
'type' => 'link',
|
||||
'label' => 'Admin',
|
||||
'href' => $adminHref,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (Route::has('logout')) {
|
||||
$items[] = [
|
||||
'type' => 'logout',
|
||||
'label' => 'Logout',
|
||||
'action' => route('logout'),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function platformUrl(string $host, string $path = ''): string
|
||||
{
|
||||
if ($host === 'home') {
|
||||
if (function_exists('ladill_home_url')) {
|
||||
return ladill_home_url($path);
|
||||
}
|
||||
|
||||
$domain = config('app.home_domain');
|
||||
if (! $domain) {
|
||||
$platform = (string) config('app.platform_domain', parse_url((string) config('app.url', ''), PHP_URL_HOST) ?: '');
|
||||
$domain = $platform !== '' ? 'home.'.$platform : (string) config('app.account_domain');
|
||||
}
|
||||
|
||||
return self::absoluteUrl((string) $domain, $path);
|
||||
}
|
||||
|
||||
if (function_exists('ladill_account_url')) {
|
||||
return ladill_account_url($path);
|
||||
}
|
||||
|
||||
return self::absoluteUrl((string) config('app.account_domain'), $path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a menu link when the signed-in user is already on that destination.
|
||||
*
|
||||
* @param array{label?: string, path?: string, host?: string} $link
|
||||
*/
|
||||
private static function isCurrentMenuLink(array $link, string $href): bool
|
||||
{
|
||||
if (app()->runningInConsole() && ! app()->runningUnitTests()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request = request();
|
||||
if (! $request) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$linkHost = strtolower((string) parse_url($href, PHP_URL_HOST));
|
||||
$currentHost = strtolower($request->getHost());
|
||||
|
||||
if ($linkHost === '' || $linkHost !== $currentHost) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentPath = trim($request->getPathInfo(), '/');
|
||||
$targetPath = trim((string) parse_url($href, PHP_URL_PATH), '/');
|
||||
|
||||
if (($link['host'] ?? 'account') === 'home') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
if (($link['path'] ?? '') === 'dashboard') {
|
||||
return in_array($currentPath, ['dashboard', 'account'], true)
|
||||
|| ($targetPath !== '' && self::pathMatchesSection($currentPath, $targetPath));
|
||||
}
|
||||
|
||||
return self::pathMatchesSection($currentPath, $targetPath);
|
||||
}
|
||||
|
||||
private static function pathMatchesSection(string $currentPath, string $targetPath): bool
|
||||
{
|
||||
if ($targetPath === '') {
|
||||
return $currentPath === '';
|
||||
}
|
||||
|
||||
return $currentPath === $targetPath
|
||||
|| str_starts_with($currentPath, $targetPath.'/');
|
||||
}
|
||||
|
||||
private static function absoluteUrl(string $domain, string $path = ''): string
|
||||
{
|
||||
$base = 'https://'.trim($domain, '/');
|
||||
|
||||
if ($path === '') {
|
||||
return $base;
|
||||
}
|
||||
|
||||
return $base.'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
private static function userIsAdmin(Authenticatable $user): bool
|
||||
{
|
||||
if (method_exists($user, 'isAdmin')) {
|
||||
return $user->isAdmin();
|
||||
}
|
||||
|
||||
return (bool) ($user->is_admin ?? false);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,24 @@ if (! function_exists('ladill_domains_url')) {
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_domains_embed_url')) {
|
||||
function ladill_domains_embed_url(string $path = '/embed/find'): string
|
||||
{
|
||||
$url = ladill_domains_url($path);
|
||||
$origin = rtrim((string) config('app.url'), '/');
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
|
||||
return $url.$separator.'parent_origin='.urlencode($origin);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_home_url')) {
|
||||
function ladill_home_url(string $path = ''): string
|
||||
{
|
||||
return 'https://home.'.config('app.platform_domain').($path !== '' ? '/'.ltrim($path, '/') : '');
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('ladill_account_url')) {
|
||||
function ladill_account_url(string $path = ''): string
|
||||
{
|
||||
|
||||
@@ -17,8 +17,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'redirect' => $request->fullUrl(),
|
||||
]));
|
||||
$middleware->web(append: [
|
||||
\App\Http\Middleware\InjectBootSplash::class,
|
||||
\App\Http\Middleware\SetActingAccount::class,
|
||||
]);
|
||||
$middleware->alias([
|
||||
'platform.session' => \App\Http\Middleware\EnsurePlatformSession::class,
|
||||
'auth.service' => \App\Http\Middleware\AuthenticateService::class,
|
||||
]);
|
||||
|
||||
// External registrar webhook posts have no CSRF token.
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
|
||||
@@ -11,4 +11,6 @@ return [
|
||||
'api_url' => env('BILLING_API_URL', 'https://ladill.com/api/billing'),
|
||||
'api_key' => env('BILLING_API_KEY_HOSTING'),
|
||||
'service' => 'hosting',
|
||||
'wallet_balance_route' => 'wallet.balance',
|
||||
'currency' => 'GHS',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('DOMAINS_API_URL', 'https://domains.ladill.com/api'),
|
||||
'api_key' => env('DOMAINS_API_KEY_HOSTING'),
|
||||
|
||||
'platform_api_url' => env('DOMAIN_REGISTRY_API_URL', 'https://ladill.com/api'),
|
||||
'platform_api_key' => env('DOMAIN_API_KEY_HOSTING'),
|
||||
];
|
||||
@@ -1,6 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
// Path to the jailed-shell launcher used by the control-panel terminal. It
|
||||
// chroots a customer into the jailkit jail (only their own home visible).
|
||||
// Provisioned on the host (see deployment/ladill-jailsh + sudoers).
|
||||
'terminal_jail_launcher' => env('HOSTING_TERMINAL_JAIL_LAUNCHER', '/usr/local/sbin/ladill-jailsh'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Central developer access
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When true, hosting developer-access authorization is sourced from the
|
||||
| central monolith (account.ladill.com) via the identity API instead of the
|
||||
| local hosting_account_members table. While false (shadow mode), local data
|
||||
| stays authoritative and any divergence from central is logged.
|
||||
|
|
||||
*/
|
||||
'central_dev_access' => env('HOSTING_CENTRAL_DEV_ACCESS', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Contabo API Configuration
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product_slug' => 'hosting',
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/hosting'),
|
||||
];
|
||||
@@ -2,39 +2,45 @@
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ladill App Launcher — SHARED, IDENTICAL across every app/service repo
|
||||
| Ladill App Launcher — GENERATED, IDENTICAL across every app/service repo
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the
|
||||
| launcher in a new app, copy these three things verbatim into that repo:
|
||||
| - config/ladill_launcher.php (this file)
|
||||
| - resources/views/partials/launcher.blade.php
|
||||
| - public/images/launcher-icons/* (the icon set)
|
||||
| DO NOT EDIT BY HAND. This file is generated from the app registry in the
|
||||
| monolith (config/ladill_apps.php). To change the launcher, edit that
|
||||
| registry and run: php artisan ladill:launcher:sync --propagate
|
||||
|
|
||||
| When a service becomes FULLY extracted: add one row below AND drop its icon in
|
||||
| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed.
|
||||
| Do NOT list a service here until it is fully extracted to its own subdomain.
|
||||
|
|
||||
| URLs are absolute (built from the platform root) so this file is byte-identical
|
||||
| in every app; the blade omits whichever row matches the app's APP_URL host.
|
||||
| Icons are served from /images/launcher-icons/<icon>.
|
||||
| URLs are absolute (built from the platform root) so this file is
|
||||
| byte-identical in every repo; the blade omits whichever row matches the
|
||||
| app's own host. Icons are served from /images/launcher-icons/<icon>.
|
||||
*/
|
||||
|
||||
$root = config('app.platform_domain', 'ladill.com');
|
||||
|
||||
return [
|
||||
'apps' => [
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Woo Manager', 'url' => 'https://woo.'.$root.'/sso/connect?redirect='.urlencode('https://woo.'.$root.'/dashboard'), 'icon' => 'woomanager.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'],
|
||||
['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'],
|
||||
['name' => 'Stock', 'url' => 'https://stock.'.$root.'/sso/connect?redirect='.urlencode('https://stock.'.$root.'/dashboard'), 'icon' => 'stock.svg'],
|
||||
['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'],
|
||||
['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'],
|
||||
['name' => 'Care', 'url' => 'https://care.'.$root.'/sso/connect?redirect='.urlencode('https://care.'.$root.'/dashboard'), 'icon' => 'care.svg'],
|
||||
['name' => 'Queue', 'url' => 'https://queue.'.$root.'/sso/connect?redirect='.urlencode('https://queue.'.$root.'/dashboard'), 'icon' => 'queue.svg'],
|
||||
['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'],
|
||||
['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'],
|
||||
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
|
||||
['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'],
|
||||
['name' => 'Meet', 'url' => 'https://meet.'.$root.'/sso/connect?redirect='.urlencode('https://meet.'.$root.'/dashboard'), 'icon' => 'meet.svg'],
|
||||
['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'],
|
||||
['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'],
|
||||
['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'],
|
||||
['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'],
|
||||
['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'],
|
||||
['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'],
|
||||
['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'],
|
||||
['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'],
|
||||
['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'],
|
||||
['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_url' => env('PDNS_API_URL', 'http://127.0.0.1:8081'),
|
||||
'api_key' => env('PDNS_API_KEY', ''),
|
||||
'server' => env('PDNS_SERVER', 'localhost'),
|
||||
'timeout' => (int) env('PDNS_TIMEOUT', 5),
|
||||
'slave_masters' => array_filter(array_map('trim', explode(',', env('PDNS_SLAVE_MASTERS', '167.86.66.168')))),
|
||||
|
||||
'slave_api_url' => env('PDNS_SLAVE_API_URL', ''),
|
||||
'slave_api_key' => env('PDNS_SLAVE_API_KEY', ''),
|
||||
'slave_server' => env('PDNS_SLAVE_SERVER', 'localhost'),
|
||||
];
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Platform service-to-service API keys
|
||||
|--------------------------------------------------------------------------
|
||||
| Bearer tokens for internal APIs called by account.ladill.com (the platform).
|
||||
| The hosting app is the sole writer for hosting accounts; admin assignment
|
||||
| and lifecycle actions arrive over these routes.
|
||||
*/
|
||||
'service_api_keys' => array_filter([
|
||||
'account' => env('PLATFORM_API_KEY_HOSTING'),
|
||||
]),
|
||||
];
|
||||
@@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'slug' => 'hosting',
|
||||
'name' => 'Hosting',
|
||||
'logo' => 'images/logo/ladillhosting-logo.svg',
|
||||
'icon' => 'hosting.svg',
|
||||
'tagline' => 'Fast, managed web hosting',
|
||||
'headline' => 'Reliable hosting for your websites',
|
||||
'accent' => 'shared, WordPress, and cloud plans',
|
||||
'description' => 'Hosting provides managed web hosting for business sites, WordPress, and multi-domain setups — with SSL, backups, and one-click tools.',
|
||||
'benefits' =>
|
||||
[
|
||||
0 =>
|
||||
[
|
||||
'title' => 'Plans for every stage',
|
||||
'text' => 'Single-domain, multi-domain, WordPress, and reseller options.',
|
||||
,]
|
||||
1 =>
|
||||
[
|
||||
'title' => 'Free SSL included',
|
||||
'text' => 'Secure every site with automatic HTTPS.',
|
||||
,]
|
||||
2 =>
|
||||
[
|
||||
'title' => 'Managed & monitored',
|
||||
'text' => '99.9% uptime with 24/7 platform support.',
|
||||
,]
|
||||
,]
|
||||
'use_cases' =>
|
||||
[
|
||||
0 => 'Business websites',
|
||||
1 => 'WordPress blogs',
|
||||
2 => 'Agency client hosting',
|
||||
,]
|
||||
'gradient_from' => '#1c75bc',
|
||||
'gradient_to' => '#010c1f',
|
||||
'dashboard_route' => 'hosting.dashboard',
|
||||
'platform_url' => 'https://localhost',
|
||||
'marketing_url' => 'https://localhost/products/hosting',
|
||||
'register_url' => 'https://auth.localhost/register',
|
||||
'landing_variant' => 'default',
|
||||
];
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ class UserFactory extends Factory
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'public_id' => (string) Str::uuid(),
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasTable('domain_dns_records')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::create('domain_dns_records', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('type', 20);
|
||||
$table->text('value');
|
||||
$table->unsignedInteger('ttl')->default(3600);
|
||||
$table->unsignedSmallInteger('priority')->nullable();
|
||||
$table->string('source')->default('managed');
|
||||
$table->string('status')->default('pending');
|
||||
$table->timestamp('last_checked_at')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['domain_id', 'source']);
|
||||
$table->index(['domain_id', 'status']);
|
||||
$table->unique(['domain_id', 'name', 'type', 'value'], 'domain_dns_records_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('domain_dns_records');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('domain_dkim_keys')) {
|
||||
Schema::create('domain_dkim_keys', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
|
||||
$table->string('selector');
|
||||
$table->longText('public_key_txt');
|
||||
$table->string('private_key_path')->nullable();
|
||||
$table->string('status')->default('active');
|
||||
$table->timestamp('generated_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['domain_id', 'selector']);
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('domain_ns_checks')) {
|
||||
Schema::create('domain_ns_checks', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('domain_id')->constrained('domains')->cascadeOnDelete();
|
||||
$table->string('check_type')->default('onboarding');
|
||||
$table->string('result')->default('pending');
|
||||
$table->json('expected_nameservers')->nullable();
|
||||
$table->json('observed_nameservers')->nullable();
|
||||
$table->boolean('has_authoritative_answer')->nullable();
|
||||
$table->unsignedSmallInteger('manual_records_total')->default(0);
|
||||
$table->unsignedSmallInteger('manual_records_verified')->default(0);
|
||||
$table->string('status_before')->nullable();
|
||||
$table->string('status_after')->nullable();
|
||||
$table->string('state_before')->nullable();
|
||||
$table->string('state_after')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamp('checked_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['domain_id', 'checked_at']);
|
||||
$table->index(['domain_id', 'result']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('domain_ns_checks');
|
||||
Schema::dropIfExists('domain_dkim_keys');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ladill jail interactive shell rc: keep the user's shell config, start in the
|
||||
# requested directory, and report the working directory to the control panel via
|
||||
# OSC 7 on every prompt so the toolbar shows the live full path.
|
||||
[ -r /etc/bash.bashrc ] && . /etc/bash.bashrc
|
||||
[ -r "$HOME/.bashrc" ] && . "$HOME/.bashrc"
|
||||
[ -n "${LADILL_START_DIR:-}" ] && cd "$LADILL_START_DIR" 2>/dev/null
|
||||
__ladill_cwd() { printf '\033]7;file://%s%s\007' "${HOSTNAME:-ladill}" "$PWD"; }
|
||||
case "$-" in *i*) PROMPT_COMMAND="__ladill_cwd${PROMPT_COMMAND:+; $PROMPT_COMMAND}"; __ladill_cwd ;; esac
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Ladill jailed shell for the hosting browser terminal. Per session (private mount
|
||||
# ns): /home holds only this user's dir, and /etc/passwd|group expose only root +
|
||||
# this user. The customer can never see other tenants, the host fs, or secrets.
|
||||
# Usage: ladill-jailsh <username> [relpath]
|
||||
set -euo pipefail
|
||||
JAIL=/home/jail
|
||||
U=${1:-}
|
||||
REL=${2:-/public_html}
|
||||
[ -n "$U" ] || { echo 'usage: ladill-jailsh <user> [relpath]'; exit 2; }
|
||||
UID_=$(id -u "$U" 2>/dev/null || true)
|
||||
GID_=$(id -g "$U" 2>/dev/null || true)
|
||||
[ -n "$UID_" ] && [ "$UID_" -ge 1000 ] || { echo 'refusing: not a hosting user'; exit 1; }
|
||||
HOMEDIR=/home/$U
|
||||
[ -d "$HOMEDIR" ] || { echo 'no home directory'; exit 1; }
|
||||
case "$REL" in *..*) REL=/public_html;; esac
|
||||
if [ -z "${LADILL_JAIL_NS:-}" ]; then
|
||||
exec env LADILL_JAIL_NS=1 unshare --mount --propagation private -- "$0" "$U" "$REL"
|
||||
fi
|
||||
# /home with only this user's directory
|
||||
mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs "$JAIL/home"
|
||||
mkdir -p "$JAIL/home/$U"
|
||||
mount --bind "$HOMEDIR" "$JAIL/home/$U"
|
||||
# passwd/group exposing only root + this user (no tenant enumeration)
|
||||
mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs "$JAIL/run"
|
||||
{ echo 'root:x:0:0:root:/root:/bin/bash'; getent passwd "$U"; } > "$JAIL/run/passwd"
|
||||
{ echo 'root:x:0:'; getent group "$U"; } > "$JAIL/run/group"
|
||||
mount --bind "$JAIL/run/passwd" "$JAIL/etc/passwd"
|
||||
mount --bind "$JAIL/run/group" "$JAIL/etc/group"
|
||||
export HOME="/home/$U" USER="$U" LOGNAME="$U" SHELL=/bin/bash PATH=/usr/bin:/bin
|
||||
export TERM="${TERM:-xterm-256color}" LADILL_START_DIR="/home/$U$REL" HOSTNAME="$(hostname 2>/dev/null || echo ladill)"
|
||||
exec /usr/sbin/chroot --userspec="$UID_:$GID_" "$JAIL" /bin/bash --rcfile /etc/ladill-jail-bashrc -i
|
||||
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# Provision the jailkit chroot used by the control-panel browser terminal so the
|
||||
# customer shell is confined to its own home (cannot see /var/www, other tenants,
|
||||
# /etc, or secrets). Idempotent; run as root on each hosting node.
|
||||
set -Eeuo pipefail
|
||||
|
||||
JAIL=/home/jail
|
||||
|
||||
echo "==> Installing jailkit"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
command -v jk_init >/dev/null 2>&1 || apt-get install -y jailkit
|
||||
|
||||
echo "==> Building jail tree at $JAIL"
|
||||
jk_init -j "$JAIL" uidbasics netbasics basicshell interactiveshell extendedshell editors netutils terminfo || true
|
||||
jk_cp -j "$JAIL" /usr/bin/id /usr/bin/whoami /usr/bin/find /usr/bin/grep /usr/bin/less \
|
||||
/usr/bin/head /usr/bin/tail /usr/bin/du /usr/bin/df /usr/bin/wc /usr/bin/clear /usr/bin/sed /usr/bin/awk || true
|
||||
|
||||
# Terminal definitions (xterm*) for a usable TTY.
|
||||
mkdir -p "$JAIL/usr/share/terminfo"
|
||||
cp -rn /usr/share/terminfo/x "$JAIL/usr/share/terminfo/" 2>/dev/null || true
|
||||
|
||||
# Non-listable jail /home (each session bind-mounts only its own home here).
|
||||
install -d -o root -g root -m 711 "$JAIL/home"
|
||||
install -d -m 1777 "$JAIL/tmp"
|
||||
|
||||
# NOTE: do NOT sync hosting users into the shared jail passwd — that would let
|
||||
# any customer enumerate every tenant. ladill-jailsh injects a per-session
|
||||
# passwd/group (root + the current user only) inside each session's namespace.
|
||||
|
||||
echo "==> Installing the interactive rc (keeps user prompt + reports cwd via OSC 7)"
|
||||
install -o root -g root -m 644 "$(dirname "$0")/ladill-jail-bashrc" "$JAIL/etc/ladill-jail-bashrc"
|
||||
|
||||
echo "==> Installing the jailed-shell launcher + scoped sudoers"
|
||||
install -o root -g root -m 755 "$(dirname "$0")/ladill-jailsh" /usr/local/sbin/ladill-jailsh
|
||||
echo 'www-data ALL=(root) NOPASSWD: /usr/local/sbin/ladill-jailsh *' > /etc/sudoers.d/ladill-jailsh
|
||||
chmod 440 /etc/sudoers.d/ladill-jailsh
|
||||
visudo -cf /etc/sudoers.d/ladill-jailsh
|
||||
|
||||
echo "==> Done. Customer terminals now run jailed in $JAIL."
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ladill-email",
|
||||
"name": "ladill-hosting",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -7,7 +7,9 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.12",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"alpinejs": "^3.15.12"
|
||||
"alpinejs": "^3.15.12",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
@@ -2489,6 +2491,23 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"dependencies": {
|
||||
"@alpinejs/collapse": "^3.15.12",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"alpinejs": "^3.15.12"
|
||||
"alpinejs": "^3.15.12",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0077c0;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M141.6.5c10.6,26.8,32.1,44.5,51.2,64.1,1.5,1.6,3.2,3.1,4.7,4.7,13.2,13.8,26.2,27.8,36,44.3,19.6,32.7,23.6,66.9,9.2,102.6-16.1,39.7-56.4,66.6-99.3,66.8-53.9.2-98.5-35.5-109.1-88-5.9-29.1.3-56.2,15.6-81.4,10.2-16.7,23.4-31,36.9-45.1,11.3-11.8,23.4-22.7,34-35.1,8.5-9.9,15.8-20.4,20.8-32.9ZM217.4,172.2c-.2-12.2-3.4-24.5-10.3-35.6-17-27.4-40.1-49.7-62.7-72.4-2.5-2.5-4.1-1.3-6,.6-5.7,5.8-11.6,11.4-17.3,17.3-8.5,8.7-8.5,14.9,0,23.9,14.2,15,28.7,29.9,42.7,45.2,13.3,14.6,27.6,28.4,39.2,44.6,3.1,4.4,7.5,3.9,9.9-1.2,3.2-6.8,4.3-14.1,4.4-22.3ZM178.5,217.4c.5-6.9-1.3-14.3-6.3-20.4-9-10.8-18.5-21.2-27.7-31.7-1.6-1.8-3.3-2.3-5.1-.3-9.8,11.3-21.1,21.4-29.3,34-8.3,12.8-6.7,30.4,2.9,42,10.2,12.5,26.1,16.8,41.7,11.3,14.3-5,23.8-18.5,23.9-35ZM67,169.9c-.2,7.4,1.6,16.7,7.4,24.8,3.2,4.6,5,4.3,8.1-.3,2.7-4,5.5-7.9,8.5-11.6,9.3-11.3,19.4-21.8,29.8-32.1,2.6-2.6,3.7-4.9.8-8-8.7-9.4-17.3-18.8-25.9-28.2-1.5-1.6-2.9-2.2-4.6-.3-13.8,15.1-24.2,31.7-24.1,55.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ef483b;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #86c169;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #fbb928;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #1ea0d8;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-3" d="M280.3,42.2c2.4,17.1-6.6,34.6-22.4,43.2-1.9,1-2.6,2-2.5,4.3.6,18.7-5.6,34.8-18.7,48.2-1.2,1.3-2.5,2.5-3.8,3.8-1.5.4-2.1-.9-2.9-1.6-4.5-4.4-8.9-8.9-13.4-13.3-4.4-4.5-8.9-8.8-13.2-13.3-.9-1-2.1-1.8-2.1-3.4.6-2.6,2.2-4.8,2.6-7.5,1.4-8.2-1.7-14.6-8-19.4-6.2-4.6-13.1-5.2-20.1-1.6-.6.3-1.2.6-1.8.9-.8.4-1.5,1-2.1,1.6-9.2,9.1-18.3,18.3-27.5,27.5-.6.6-1.1,1.4-1.7,2-8.4,7.7-16.2,16.1-24.4,24.1-1.5,1.5-2.8,3.3-4.8,4.3-1.4,0-2.1-1-2.9-1.8-8.4-8.3-16.7-16.7-25.1-25.1-.9-.9-2-1.7-2-3.2.3-1.3,1.4-2.1,2.3-3,20-20,39.9-40.2,60.1-60,11-10.7,24.3-17,39.8-18,2.9-.2,5.8-.8,8.8-.2,1.6.3,2.2-1,2.8-2.1,3.3-6.1,8-11,13.5-15.1,15.5-11.6,39.4-10.5,54.2,2.1,8.4,7.2,13.5,16,15.3,26.8Z"/>
|
||||
<path class="cls-2" d="M141,168.6c-8.5-8.6-17.1-17.2-25.7-25.7-.6-.6-1.2-1.4-2.2-1.2-9.8-9.8-19.5-19.6-29.3-29.5-.3-1-.8-1.8-1.3-2.7-1.9-3.3-3.1-6.9-3.1-10.7.2-14.3,15.2-23.6,28-17.6.9.4,1.7.9,2.7,1,1.3,0,2.1-.9,2.9-1.8,8.5-8.5,17-16.9,25.4-25.4.9-.9,2-1.7,1.8-3.2-.4-1.2-1.3-2-2.1-2.8-3.7-3.7-7.9-6.8-12.4-9.5-11.1-6.6-23.1-9.6-36.1-9.1-1.6,0-2.5-.4-3.3-1.8-1.1-1.9-2.2-4-4-5.4,0,0-.1,0-.2,0,.1-.8-.5-1.4-.9-2C67.4,3.7,39.6-.1,21.6,13.1,11.5,20.5,4.9,30.1,3.2,42.8c-.1,1.3-.4,2.5-.4,3.8.4,17,7.8,30,22.6,38.6,2.2,1.3,2.9,2.6,2.8,5-.4,11,1.7,21.5,7,31.3,0,0,0,0,0,0,0,0,0,0,0,0,.5,1,1,2.1,1.6,3.1,3.6,6.5,8.8,11.8,13.9,17.1.4,1.4,1.6,2.3,2.5,3.2,9.5,9.5,19,18.9,28.4,28.5,8.5,8.5,17,17,25.5,25.5,1,1,1.8,2.1,3.3,2.4,1.2,0,2-.7,2.8-1.4,9.1-9.1,18.2-18.2,27.3-27.3.7-.7,1.5-1.4,1.4-2.5-.2-.6-.6-1-1-1.5Z"/>
|
||||
<path class="cls-1" d="M257.6,197.8c-1.8-1-2.4-2-2.3-4.1.6-11.4-1.9-22.3-7.1-32.5-3.8-7.6-9.5-13.7-15.4-19.7-5.3-5.1-10.5-10.2-15.8-15.4-.8-.9-1.6-1.7-2.4-2.6-4.3-4.5-9.1-8.7-13.1-13.5-.3-.9-.9-1.5-1.5-2.1-8.2-8.2-16.3-16.3-24.5-24.5-.7-.7-1.4-1.4-2.4-1.6-.5.4-1,.6-1.4,1.1-9.6,9.5-19.1,19-28.6,28.6-.4.4-.6,1-.9,1.5-.3,1.7,1.1,2.4,2,3.3,8.5,8.6,17.1,17.1,25.6,25.6,4.5,4.4,8.9,8.9,13.3,13.3.7.7,1.3,1.9,2.6,1.8.2,1.3,1.2,2,2,2.9,3.8,3.9,7.8,7.7,11.6,11.6.6.6,1,1.3,1.5,2,4.7,7.4,4.7,14.9-.3,21.9-4.9,6.8-11.8,9.9-20.4,8.1-2.4-.5-4.3-2-6.6-2.5-1.2,0-2,.8-2.7,1.6-8.8,8.8-17.6,17.6-26.4,26.4-.8.8-2,1.5-1.3,3,1.4,1.4,2.8,2.8,4.3,4.1,13.2,11.8,28.7,17.3,46.3,16.7,1.6,0,2.6.3,3.5,1.6,1.1,1.6,1.9,3.4,3.1,5,11.7,15,27,21.4,45.6,17.5,16.6-3.5,27.9-13.7,32.9-30.2.3-.9.4-1.9,1.1-2.6.1-.3.3-.5.4-.8,3-18.4-6-36.7-22.5-45.6Z"/>
|
||||
<path class="cls-4" d="M186.2,157c-5.2-5.1-10.4-10.2-15.6-15.3-9.6,9.5-19.3,19-28.9,28.6-9.8,9.8-19.6,19.7-29.4,29.6-.6.6-1.2,1-2,1.2-4,1.7-8.1,2.8-12.3,3.9,0,0,0,0,0,0,0,0,0,0,0-.1-.5-1.4-1.7-1.1-2.7-1.3-9.5-1.7-17.4-11.8-15.8-22,.5-3,2.4-5.4,2.4-8.4-10.2-10.7-20.7-21.1-31.3-31.4-15.5,14.3-23.5,31.8-22.5,53.1,0,1.7-.8,2.2-2,2.8-5.6,2.9-10.4,6.8-14.1,11.9C2.4,222.7,0,236.9,6,252.4c.8.5,1,1.4,1.5,2.2,7.4,12.8,18.2,20.9,32.9,23.1,5.6.8,11.4,1.3,16.9-1,.3-.2.6-.5.9-.7.3,0,.6-.1.9-.2,11.9-3.4,20.5-10.9,26.6-21.4.8-1.3,1.4-2.9,3.3-2.3,4.2,1.3,8.3.2,12.3-.3,14.8-2.1,27.3-8.8,37.9-19.4,19.4-19.5,38.9-38.9,58.4-58.4.8-.8,1.6-1.8,2.3-2.6-4.6-4.7-9.1-9.5-13.7-14.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ff2d20;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M62.9,0c5.2,2.8,10.3,5.8,15.4,8.8,12.8,7.3,25.5,14.7,38.3,22,2,1.1,2.9,2.7,2.9,5,0,37,0,74.1,0,111.1,0,.3,0,.7,0,1,0,.9.2,1,1,.6,12.8-7.4,25.6-14.8,38.5-22.2,1.3-.7,2.5-1.5,3.8-2.1.9-.4,1.1-1,1.1-2,0-18.2,0-36.5,0-54.7,0-.5,0-1.1,0-1.6-.1-2.2.8-3.7,2.7-4.8,4-2.3,7.9-4.5,11.8-6.8,13.2-7.6,26.5-15.2,39.7-22.8.5-.3.9-.5,1.4-.8,1.5-.8,3-.8,4.5,0,15.3,8.8,30.6,17.6,45.9,26.4,2.2,1.2,4.3,2.5,6.5,3.7,2.2,1.1,3.2,2.9,3.1,5.3,0,1.5,0,3,0,4.4,0,17.5,0,35,0,52.5,0,1,0,2-.1,3,0,1.9-1,3.2-2.6,4.2-4.8,2.7-9.5,5.5-14.3,8.2-11.6,6.7-23.2,13.4-34.8,20-1.2.7-1.6,1.4-1.6,2.8,0,18.5,0,37,0,55.5,0,2.5-.9,4.1-3.1,5.3-28.3,16.3-56.6,32.6-84.9,48.9-6.7,3.8-13.3,7.6-20,11.5-2.2,1.3-4.1,1.2-6.3,0-20.9-12.1-41.8-24.1-62.7-36.1-14.2-8.2-28.3-16.3-42.5-24.5-1.9-1.1-2.8-2.5-2.8-4.8,0-60.5,0-121,0-181.5,0-2.2.9-3.6,2.7-4.7C23.3,21.3,40,11.7,56.8,2.1,58,1.4,59.3.7,60.5,0,61.3,0,62.1,0,62.9,0ZM12.9,129c0,28.2,0,56.3,0,84.5,0,1.2.4,1.8,1.4,2.4,5.4,3,10.8,6.2,16.2,9.3,26.3,15.2,52.7,30.3,79,45.5,1,.6,1,.6,1-.6,0-16.2,0-32.4,0-48.6,0-.7-.2-1.2-.9-1.6-3.4-1.9-6.7-3.8-10.1-5.7-13-7.3-25.9-14.7-38.9-22-2.4-1.3-3.4-3-3.4-5.8,0-38.8,0-77.6,0-116.3,0-1.2-.4-1.8-1.4-2.4-9.8-5.6-19.7-11.3-29.5-16.9-4.2-2.4-8.3-4.8-12.5-7.2-.9-.5-1.1-.3-1,.6,0,.3,0,.6,0,.9,0,28,0,56,0,84.1ZM119.5,245.6c0,8.1,0,16.2,0,24.4,0,1.1,0,1.2,1,.6.1,0,.2-.1.4-.2,11.1-6.4,22.2-12.8,33.3-19.2,20.5-11.8,41-23.6,61.6-35.4,1-.6,1.3-1.2,1.3-2.3,0-13.5,0-26.9,0-40.4,0-2.5,0-4.9,0-7.4,0-.9-.3-1-1-.5-1.6,1-3.3,1.9-5,2.9-16.6,9.5-33.2,19-49.8,28.5-13.6,7.7-27.1,15.5-40.7,23.2-.8.5-1.1,1-1.1,1.9,0,8,0,16,0,24ZM110.5,98.7c0-18.1,0-36.2,0-54.4,0-1.4,0-1.4-1.1-.7-13.9,8-27.8,16-41.8,24-1,.6-1.4,1.2-1.4,2.5,0,30.6,0,61.1,0,91.7,0,5.5,0,11,0,16.6,0,1.2,0,1.3,1.1.7,13.9-8,27.9-16.1,41.9-24.1,1-.6,1.4-1.2,1.4-2.4,0-18,0-36,0-53.9ZM212.2,156.8c-.2-.3-.5-.4-.8-.6-14-8.1-28-16.1-42-24.2-.8-.5-1.4-.4-2.2,0-5,2.9-10,5.8-15,8.7-16,9.2-31.9,18.4-47.9,27.5-10.8,6.2-21.6,12.5-32.5,18.7-.3.2-.7.2-.7.6,0,.4.4.4.7.6,14.1,8,28.1,15.9,42.2,23.9.6.4,1.1.4,1.8,0,9.3-5.3,18.5-10.6,27.8-15.9,18.6-10.6,37.3-21.3,55.9-31.9,4-2.3,8-4.5,11.9-6.8.2-.1.6-.2.6-.6ZM265.8,65.3c-.4-.3-.6-.4-.9-.6-14-8.1-28.1-16.2-42.1-24.3-.8-.5-1.4-.5-2.2,0-14,8.1-28,16.1-42,24.2-1.1.6-1.1.7,0,1.3,13.8,7.9,27.5,15.8,41.3,23.8,1.3.8,2.3.7,3.6,0,11.7-6.8,23.4-13.5,35.1-20.2,2.4-1.4,4.7-2.7,7.2-4.2ZM105.6,35c-.2-.1-.5-.3-.7-.5-14.1-8.1-28.1-16.2-42.2-24.3-.7-.4-1.3-.4-2,0-14,8.1-28.1,16.2-42.1,24.3-1,.6-1,.6,0,1.3,14.1,8.1,28.2,16.2,42.2,24.3.7.4,1.1.4,1.8,0,11.2-6.5,22.4-12.9,33.7-19.4,2.8-1.6,5.7-3.3,8.5-4.9.3-.2.7-.3.8-.7ZM217.1,123.8c0-8,0-16,0-23.9,0-.9-.3-1.4-1.1-1.9-2.4-1.3-4.8-2.8-7.2-4.1-11.6-6.7-23.1-13.3-34.7-20-1.2-.7-1.3-.7-1.3.7,0,15.9,0,31.8,0,47.6,0,.9.2,1.4,1,1.9,14.1,8.1,28.1,16.1,42.1,24.2,1.1.6,1.1.6,1.1-.6,0-8,0-16,0-23.9ZM226.1,123.7c0,8,0,16,0,23.9,0,.4-.2.9.2,1.1.4.2.7-.2,1-.4,14-8.1,28-16.1,42-24.2.8-.5,1.1-1,1.1-1.9,0-15.9,0-31.8,0-47.6,0-1.4,0-1.4-1.3-.7-14,8.1-27.9,16.1-41.9,24.1-.9.5-1.1,1-1.1,2,0,7.9,0,15.8,0,23.7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #e86737;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M20.1,140.8c0-19.4.2-38.9-.1-58.3-.1-7.2,2.1-12,8.5-15.9C63.7,45.7,98.7,24.5,133.6,3.1c6-3.7,10.7-3.7,16.8,0,34.9,21.4,69.9,42.6,105,63.5,6.4,3.8,8.6,8.6,8.6,15.8-.2,39.2-.3,78.4,0,117.6,0,8.3-2.5,13.7-10,17-7.1,3.1-14.2,12.8-20.3,9.4-6.9-3.9-2.7-15-2.8-22.9-.3-34.1-.3-68.2,0-102.3,0-7.3-2.3-11.9-8.7-15.6-24.9-14.7-49.6-29.9-74.3-45.1-4.4-2.7-7.8-2.6-12.2.1-24.9,15.4-49.9,30.7-75.1,45.7-5.9,3.5-7.5,7.8-7.5,14.2.2,38.5.1,77.1,0,115.6,0,3.6,1.5,8.5-1.7,10.6-4,2.7-7.3-2.1-11-3.4-16.8-6.1-22.7-17.6-20.8-35.5,1.7-15.5.3-31.3.3-47Z"/>
|
||||
<path class="cls-1" d="M126.9,167.4c0,23.2,0,46.3,0,69.5,0,9.1,7,17.2,14.8,17.5,7.8.3,15.3-8.4,15.3-17.9,0-46.3,0-92.7,0-139,0-3.6-1.7-8.3,2.1-10.4,3.8-2.1,6.6,2.4,10,3.6,26.7,9.5,34.6,28.1,32.1,55.9-2.8,30.4-.8,61.3-.5,92,0,7.8-2.5,12.6-9.3,16.4-14.2,8.1-28.1,16.7-41.9,25.5-5.3,3.4-9.6,3.5-15,.1-14.4-9.2-28.9-18-43.6-26.6-5.3-3.1-7.5-7.2-7.5-13.3.1-40.6.2-81.1,0-121.7,0-6.6,2.5-10.8,8.1-14,8.3-4.7,16.3-10,24.4-14.9,9.6-5.8,10.8-5.2,10.9,5.8.1,23.9,0,47.7,0,71.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #80bd00;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M16.3,141.9c0-19.9,0-39.7,0-59.6,0-9.3,4.3-15.8,12.1-20.3,10.9-6.4,21.8-12.7,32.7-19,20.3-11.7,40.7-23.4,61-35.1,1.8-1,3.6-2,5.3-3.1,9.4-6.1,18.6-5.8,28.3,0,7.5,4.5,15.2,8.9,22.8,13.2,18.6,10.6,37.1,21.5,55.6,32.2,6.6,3.8,13.2,7.6,19.9,11.3,8.9,4.9,13.2,12.5,13.2,22.6,0,38.5,0,77,0,115.4,0,10-4.4,17.4-13.4,22.4-17.1,9.6-34,19.7-51,29.5-14.1,8.1-28.4,16.2-42.5,24.3-4.5,2.6-8.7,5.7-13.8,6.9-6.4,1.5-12.2,0-17.7-3.2-10.9-6.4-21.7-12.9-32.5-19.4-.3-.2-.5-.3-.8-.4-1.1-.9-2.8-1.6-2.8-3.2,0-1.7,2-2,3.3-2.4,4.8-1.7,9.5-3.7,13.8-6.4,1.6-1,3-.3,4.4.5,6.7,4,13.5,8,20.2,12,.3.2.7.4,1,.7,3.7,3.1,7,2.9,11.3.3,25-14.7,50.3-29.1,75.5-43.6,7.3-4.2,14.5-8.5,21.8-12.7,2.6-1.5,3.9-3.4,3.7-6.4-.2-3-.6-6.1-.4-9.1.4-9.3-.2-18.6,0-27.8.2-7.1-.3-14.3,0-21.4.3-8.3-.4-16.6,0-24.9.6-10.3-.6-20.5,0-30.8.1-3-.9-4.8-3.6-6.3-16.5-9.3-32.9-18.8-49.4-28.3-12.1-7-24.2-14.1-36.3-21-4.6-2.6-9.3-5-13.7-7.9-1.9-1.3-3.7-1.2-5.7,0-12.5,7.3-25,14.6-37.6,21.8-20.8,12-41.5,23.9-62.3,35.8-2.1,1.2-3,2.7-3,5.2,0,38.8,0,77.5,0,116.3,0,2.3.8,3.6,2.8,4.7,8.3,4.8,16.5,9.7,24.9,14.4,4.2,2.4,8.7,3.9,13.7,3.7,6.6-.3,12-5.6,12.1-12.3,0-8.1,0-16.3,0-24.4,0-30.3,0-60.5,0-90.8,0-5.7.9-6.6,6.7-6.6,2.9,0,5.9,0,8.8,0,3.4,0,4.5,1.2,4.7,4.6,0,.8,0,1.6,0,2.3,0,38.5,0,77,0,115.4,0,7.9-1.8,15.6-7,21.9-4.5,5.5-10.6,8-17.5,9.2-12.5,2.2-23.5-1.8-34.1-7.9-7.4-4.3-15-8.5-22.3-12.9-7.5-4.5-12.1-10.9-12-20.1,0-19.8,0-39.6,0-59.3h0Z"/>
|
||||
<path class="cls-1" d="M169,85.7c10.7,0,21.3.9,31.3,5.3,10.4,4.5,16.8,12.4,19.4,23.4.6,2.5,1.3,4.9,1.2,7.5-.2,2.5-1.3,3.8-3.8,3.9-3.6,0-7.2,0-10.9,0-4.2,0-4.8-.4-5.9-4.5-2.1-8-7.2-13.1-15.2-15-3.1-.7-6.3-1.3-9.5-1.4-7.5-.3-15.1-.4-22.4,1.8-5,1.6-8.9,4.5-10.1,10-1,4.4.9,7.9,5,9.8,5.6,2.5,11.7,2.9,17.7,4,13.2,2.4,26.9,2.8,39.7,7.5,6.6,2.4,12.8,5.5,16.3,12.1,4.1,7.8,4.4,15.9,2.2,24.2-3.8,14.7-15.2,20.9-28.6,24.2-9.3,2.3-18.8,2.7-28.5,2.2-7.2-.4-14.2-1.2-21.1-3.2-13.4-3.9-23.4-11.6-26.7-26-.7-3.1-.5-6.2-.4-9.4,0-1.9,1.1-2.9,3-2.9,4.5,0,9,0,13.5,0,3.4,0,3.2,3,3.6,5.2,1.1,6.2,4.3,10.9,9.9,13.8,4.1,2.1,8.7,2.9,13.3,3.4,7.5.8,15,.6,22.5,0,5.1-.4,10-1.7,14.3-4.7,4.4-3.1,6.4-9.4,4.4-14.4-1.6-4.1-5.6-5.3-9.3-6.4-7.7-2.4-15.6-3.5-23.6-4.2-10.4-.9-20.8-2.3-30.6-5.9-8.1-3-14.3-8.2-16.7-16.8-2.8-10.4-1.6-20.3,5.3-28.8,7.2-8.9,17.6-11.9,28.4-13.7,4.2-.7,8.4-.8,12.6-.7Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #51bbee;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M141.9,283.5C63.2,283.5,0,220.5,0,141.9,0,63.2,63,0,141.6,0c79,0,142,63.1,141.9,142.3,0,78.1-63.3,141.2-141.6,141.2ZM206,130.4c-.5,1-.9,2.4-1.8,3.4-2.5,3.1-5.1,6.2-7.7,9.2-5.3,6-10.8,11.9-15.9,18-1.7,2-4.7,4.9-2.2,7.3,2.9,2.8,4.7-1.3,6.8-2.8,2.4-1.9,4.6-4,7-6,12.1-10.2,24.6-19.9,35.1-31.8,8.1-9.2,5.8-16.5-6-19.7-8.9-2.4-18.2-2.8-27.3-3-23.3-.5-46.7.5-70-1.6-20.3-1.8-39.8-5.4-54.8-21-1.3-1.3-2.8-3.3-4.8-1.7-2.2,1.8-.2,3.8.7,5.7,9.3,18.5,25.6,26.9,44.8,31.1,22.3,4.8,45,4.3,67.6,4.3,8,0,16,.8,23.7,3.1,2.4.7,5.1,1.7,4.9,5.5ZM105.8,181.4c-8,.1-13.6,5.9-13.6,14,0,7.8,6.2,14.1,13.8,14,7.7,0,14.4-7,14.3-14.6-.2-7.6-6.6-13.5-14.4-13.4ZM173,195.2c0-7.8-5.4-13.5-13.1-13.7-7.5-.2-14.6,6.2-14.7,13.3-.1,7.7,6.7,14.8,14.3,14.7,7.8-.1,13.5-6.1,13.5-14.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fdd546;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #387ab0;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M280.7,135.9c-.2-.4,0-.8-.1-1.2-.1-4.9-.2-9.8-.9-14.7-1.2-7.6-2.9-15.1-6-22.2-3.1-7-7.2-13.3-13.3-18-5.2-4.1-11.2-6.6-17.8-6.8-7.9-.3-15.9,0-23.8,0-1.3,0-1.6.4-1.7,1.7,0,.5,0,.9,0,1.4,0,9.8,0,19.5,0,29.3,0,2.4,0,4.8-.4,7.2-.8,5.9-2.5,11.5-5.6,16.6-3.2,5.3-7.5,9.4-13,12.2-5.6,2.9-11.7,4.1-18,4.1-22.8,0-45.7,0-68.5,0-1.5,0-3,.1-4.5.3-6,.8-11.8,2.4-17.1,5.5-3.7,2.2-7,4.9-9.6,8.4-4.6,6-6.7,13-6.7,20.5-.1,21.5,0,42.9,0,64.4,0,4.3,1,8.3,2.8,12.1,1.7,3.5,4.1,6.5,7,9.2,2.6,2.4,5.4,4.3,8.5,5.9,5.6,3,11.6,4.9,17.7,6.3,5.7,1.2,11.3,2.2,17.1,2.3,2.7,0,5.5.2,8.2.2.2,0,.4,0,.5.1h13.9c2-.3,4-.1,6-.2,4.4-.1,8.7-.4,13.1-1,7.2-1.1,14.3-2.7,21-5.5,5-2.1,9.6-4.9,13.4-8.9,4.1-4.3,6.3-9.3,6.3-15.4,0-9.7,0-19.4,0-29.1q0-3-2.9-3c-20.7,0-41.4,0-62.1,0-.4,0-.8,0-1.2,0-1.4,0-1.5-.1-1.5-1.5,0-1.6,0-3.3,0-4.9q0-2.2,2.3-2.2c30.5,0,60.9,0,91.4,0,2.7,0,5.4-.2,8.1-.6,8.3-1.3,15.4-4.9,21.3-10.8,4.4-4.3,7.6-9.5,10-15.1,3.9-9.3,6-19.1,6.1-29.2,0-1.7,0-3.4.2-5.1v-12.4ZM179.4,235c6.7,0,12.2,5.4,12.2,12.1,0,6.7-5.4,12.1-12.1,12.2-6.8,0-12.2-5.3-12.2-12.1,0-6.8,5.3-12.1,12.1-12.2Z"/>
|
||||
<path class="cls-2" d="M209.6,56.2c0-6.2,0-12.4,0-18.6,0-4.2-1.2-8.2-3.2-11.9-5-9-13.2-13.9-22.6-17.1-.2,0-.3-.2-.5-.2-9.1-3.1-18.5-4.5-28.1-5.2-7.8-.6-15.7-.6-23.5-.3-9.4.4-18.7,1.4-27.8,3.7-6.4,1.7-12.6,3.9-18.1,7.8-7.1,5-11,11.7-10.8,20.7.2,9.6,0,19.2,0,28.9,0,1,.2,1.3,1.3,1.3,16.5,0,33,0,49.5,0,.4,0,.7,0,1.1,0,4.8,0,9.7,0,14.5,0,.9,0,1.3.2,1.3,1.2,0,2.2,0,4.4,0,6.6q0,1.7-1.7,1.7c-30.9,0-61.7,0-92.6,0-5.5,0-10.9.8-16,2.9-9.3,3.7-15.9,10.3-20.6,19-5.4,10.2-7.5,21.3-8.4,32.6-.6,7.8-.6,15.7,0,23.5.5,7.3,1.5,14.5,3.4,21.5,2,7.8,5,15.2,9.8,21.7,6.9,9.3,16,14.4,27.8,14.1,6.8-.1,13.6,0,20.4,0,1.2,0,1.4-.4,1.4-1.4,0-10.7,0-21.5,0-32.2,0-4.7.8-9.4,2.2-13.9,2.8-8.8,7.9-15.9,16.1-20.5,6.3-3.5,13.1-4.8,20.1-4.8,22.2,0,44.4,0,66.7,0,3.6,0,7.1-.3,10.6-1.1,6.6-1.5,12.6-4.1,17.7-8.7,6.9-6.4,10-14.7,10.2-23.9.2-15.8.1-31.6.1-47.4ZM103.9,48.5c-6.8-.1-12.2-5.6-12.1-12.3.1-6.7,5.6-12.1,12.3-12,6.9,0,12.1,5.6,12,12.5-.1,6.6-5.7,11.9-12.2,11.8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 283.5 283.5">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0071bc;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M1.8,141.6C1.4,65,64.9,1.6,142,1.7c76.6.1,139.8,63.5,139.7,140.2-.1,76.7-63.5,140.1-140.4,139.8C64.5,281.5,1.2,218.2,1.8,141.6ZM112.6,211.9c.7-1.5.9-2,1.1-2.5,7.9-23.6,15.8-47.3,23.7-70.9.7-2,.3-3.6-.4-5.4-5.4-14.1-10.8-28.2-16.3-42.3-2.7-6.9-1.7-5.8-8.7-5.9-1.4,0-2.8,0-4.2,0-3.6-.3-6.1-2.7-5.9-5.7.2-3,2.4-5,6.1-5,21.7,0,43.3,0,65,0,3.5,0,5.8,2.2,5.9,5.2,0,2.9-2.2,5.2-5.7,5.4-2.8.2-5.6,0-8.4,0-4.1,0-4.3.3-2.9,4.2,13.3,38.8,26.7,77.7,40.1,116.5.3,1,.2,2.4,2.1,2.7,2.3-7.5,4.8-14.9,6.9-22.5,4.3-15.4,10.3-30.4,11.1-46.7.7-13.8-1.5-26.8-10.2-38.1-4.1-5.3-8-10.7-11.3-16.5-7.2-12.7.7-29.3,15.1-31.7,1.7-.3,3.5-.3,6-.2-27.1-23-57.6-33.3-92.3-29.6-34.6,3.6-62.1,20.1-83.3,47.4-.7.9-2,1.9-1.5,3,.6,1.4,2.3.7,3.5.7,11.4,0,22.7,0,34.1,0,4,0,6.5,2.1,6.5,5.3,0,3.2-2.5,5.3-6.5,5.4-2.5,0-4.9,0-7.4,0-4.5,0-4.5,0-3.1,4.3,11.7,34.7,23.3,69.4,35,104.2,2,5.9,3.9,11.7,6.2,18.6ZM144.4,154.1c-.6,1.1-.8,1.5-1,2-11.7,32.3-23.3,64.6-35.1,96.9-1.1,3.1.4,3.6,2.7,4.2,22.3,5.9,44.4,5.2,66.4-1.3,3.1-.9,3.7-2.2,2.6-5.3-7.9-21.2-15.7-42.5-23.5-63.8-3.9-10.6-7.8-21.2-12-32.6ZM32.4,95.7c-.4,0-.7,0-1.1,0-13,33.4-12.4,66.5,3.4,99,11.1,22.8,40.3,51.3,54,52.9-18.8-50.8-37.5-101.4-56.3-152ZM252.2,97.5c-1.2,2.4-.8,4.5-1.2,6.6-2.7,13.2-7.7,25.8-11.9,38.5-10.4,31.7-21.2,63.2-31.7,94.8-.4,1.3-1.3,2.6-.9,4.3,50.3-31.5,66.7-96.2,45.7-144.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #000;
|
||||
stroke-width: 0px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M254.1,115.6c-16.6-14.4-24.8-32.3-23.6-54.1.9-17,7.8-31.6,20.3-43.2,27-25.2,68.3-22.5,91.9,3.9,13.7,15.3,19.1,33.5,15.7,53.8-3.4,20.4-14.6,35.5-32.3,45.9,0,.4.4.4.6.5,5.9,3.1,11.1,7.2,15.6,12,3.8,4,7,8.5,9.6,13.4,3.9,7.2,6.3,14.9,7.4,23,.5,3.8.8,7.6.6,11.5,0,2-.2,3.9-.4,5.9,0,.6,0,1.1,0,1.7,0,34.9,0,69.8,0,104.7,0,7.2-1.1,14.3-3.4,21.1-2.8,8.2-7,15.5-12.8,22-5.6,6.3-12.2,11.4-19.7,15.2-5.8,3-12,5-18.5,6-2.7.4-5.5.7-8.3.8-13.3.5-25.6-2.8-36.9-9.7-4.6-2.8-8.8-6.2-12.6-10.2-6.2-6.5-10.9-13.9-14-22.3-2.1-5.5-3.3-11.3-3.9-17.1-.1-1.6-.2-3.1-.2-4.7,0-16.5,0-33,0-49.5,0-1.9,0-1.7-1.7-1.7-32.2,0-64.4,0-96.6,0-1.7,0-1.6-.1-1.6,1.7,0,16.6,0,33.2,0,49.8,0,9.1-2,17.8-5.8,26.1-2.6,5.8-6.1,11.1-10.2,15.8-5,5.6-10.7,10.3-17.3,13.9-6.6,3.7-13.7,6.1-21.1,7.2-2.5.4-5,.6-7.5.7-2.5.2-4.9,0-7.4-.1-4.8-.3-9.5-1.2-14-2.6-9.4-2.9-17.7-7.7-25-14.3-7.6-7-13.1-15.4-16.8-25.1-1.9-5.2-3.1-10.6-3.7-16.1-.2-1.9-.3-3.7-.3-5.6,0-77.1,0-154.3,0-231.4,0-9.7,2.3-18.9,6.5-27.6,3.4-6.9,7.8-13,13.3-18.3C29.1,10.2,39.7,4.7,51.9,2.2c3.4-.7,6.8-1.1,10.2-1.2,11.7-.5,22.7,2.1,33,7.6,8,4.3,14.8,10.1,20.4,17.2,6.2,8,10.3,16.9,12.3,26.8.6,2.9,1,5.9,1.2,8.9.1,2.1.1,4.3.1,6.4,0,15.4,0,30.7,0,46.1,0,1.8,0,1.6,1.7,1.6,40.6,0,81.3,0,121.9,0h1.3ZM243.4,140.1c-.4-.2-.8-.2-1.2-.2-37.1,0-74.2,0-111.4,0q-1.6,0-1.6,1.6c0,25.8,0,51.5,0,77.3q0,1.6,1.6,1.6c32.2,0,64.5,0,96.7,0q1.6,0,1.6-1.6c0-12.4,0-24.8,0-37.3,0-5.1.5-10.1,1.6-15,1.9-8.4,5.3-16.1,10.2-23.1.8-1.1,1.6-2.2,2.5-3.4ZM24.6,226.6c-.2.5-.1.9-.1,1.4,0,22.3,0,44.7,0,67,0,1.5,0,3,.2,4.5.5,4.8,1.7,9.3,3.8,13.6,2.5,5.1,5.9,9.5,10.2,13.1,2.6,2.2,5.4,4.1,8.5,5.6,4.6,2.2,9.4,3.5,14.5,3.9,3.5.3,7,0,10.5-.6,8.2-1.6,15.3-5.3,21.1-11.3,7.7-7.8,11.5-17.2,11.6-28.1.1-16.5,0-32.9,0-49.4,0-1.7,0-1.5-1.6-1.5-12.1,0-24.2,0-36.2,0-1.7,0-3.3,0-5-.2-3.2-.3-6.4-.7-9.5-1.6-10.3-2.7-19.3-7.8-26.9-15.3-.3-.3-.6-.7-1.1-1ZM335,232.7c-.5.2-.7.4-1.1.7-10.2,7.4-21.6,11.3-34.3,11.3-14.9,0-29.8,0-44.7,0-1.6,0-1.5,0-1.5,1.5,0,16.2,0,32.3,0,48.5,0,2,.1,4.1.4,6.1,1.5,9.6,5.7,17.7,12.8,24.2,9.6,8.7,20.8,12.1,33.6,10.3,8.3-1.1,15.6-4.7,21.7-10.4,8.6-8.1,13.1-18.1,13.1-29.9,0-20.4,0-40.8,0-61.2,0-.3,0-.7-.1-1.1ZM24.6,133.4c.1,0,.2,0,.2-.1,2.5-2.5,5.1-4.9,8-6.9,9.9-7,20.8-10.7,32.9-10.7,12.5,0,25,0,37.5,0,1.7,0,1.6.1,1.6-1.6,0-16.1,0-32.1,0-48.2,0-2.1-.1-4.2-.4-6.3-1.2-7.9-4.4-15-9.8-20.9-7.5-8.3-17-12.8-28.1-13.5-3.1-.2-6.2,0-9.3.7-8.9,1.7-16.4,5.9-22.5,12.6-6.8,7.6-10.3,16.5-10.4,26.6,0,22.4,0,44.9,0,67.3,0,.3,0,.7.1,1ZM278.3,220.4c7.1,0,14.3,0,21.4,0,2,0,4-.2,6-.5,8.4-1.7,15.3-6,20.6-12.6,3.9-4.8,6.5-10.4,8-16.4.3-1.3.7-2.6.7-3.9,0-3.3.1-6.6-.2-9.9-.4-5-1.8-9.7-3.9-14.2-2.6-5.4-6.3-10-11-13.8-4.9-4-10.4-6.7-16.6-8.1-3.4-.8-6.8-1-10.2-1-2.7,0-5.3.3-7.9.9-8.6,2-15.9,6.3-21.7,13-6.6,7.6-10,16.4-10.1,26.5-.1,12.9,0,25.7,0,38.6q0,1.5,1.5,1.5c7.8,0,15.6,0,23.3,0ZM104.9,180.1c0-12.8,0-25.7,0-38.5,0-.3,0-.6,0-.9,0-.6-.1-.7-.8-.8-.2,0-.3,0-.5,0-12.7,0-25.5,0-38.2,0-3.2,0-6.2.5-9.2,1.5-7.6,2.5-13.5,7.3-18,13.8-6.4,9.4-8.7,19.8-7.2,31,1.4,10.6,6.2,19.5,14.5,26.4,6.1,5,13,7.7,21,7.7,12.3,0,24.7,0,37,0q1.5,0,1.5-1.5c0-12.9,0-25.8,0-38.7ZM294.8,105.6c21.1.2,40.2-16.9,40.2-40.2,0-22.8-18.4-40.1-39.9-40.3-22.8-.1-40.4,18.5-40.5,40-.1,22.3,18.2,40.7,40.2,40.4Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #009e6b;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #db5443;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #00a3d7;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-3" d="M1.7,170.6c0,9,.1,18.2,0,27.2-.1,8.8,4.3,15,13.8,19.3,17.3,7.5,34.4,15.2,51.7,22.8,69,30.6,138.2,61.1,207.1,92,16.9,7.6,37.1-.3,36.9-17.8v-54c0-17.9-4.5-15.2-14.4-19.4-47-20.7-94-41.6-141-62.4-39-17.3-78.2-34.2-117-51.9C19.9,117.7,0,128.1,1.3,144.4c.6,8.8.1,17.6.1,26.3h.3Z"/>
|
||||
<path class="cls-1" d="M194.6,25.4c9,0,18.2.1,27.2,0,8.8-.1,15,4.3,19.3,13.8,7.5,17.3,15.2,34.4,22.8,51.7,30.6,69.1,60.9,138.3,91.9,207.2,7.6,16.9-.3,37.1-17.8,36.9h-54c-17.9,0-15.2-4.5-19.4-14.4-20.7-47-41.6-94-62.4-141-17.3-39-34.2-78.2-51.9-117-8.7-18.8,1.8-38.7,18.1-37.4,8.8.6,17.6.1,26.3.1h0Z"/>
|
||||
<path class="cls-2" d="M194.9,25.3c-9,0-18.2.1-27.2,0-8.8-.1-15,4.3-19.3,13.8-7.5,17.3-15.2,34.4-22.8,51.7-30.6,69.1-60.9,138.3-91.9,207.2-7.6,16.9.3,37.1,17.8,36.9h54c17.9,0,15.2-4.5,19.4-14.4,20.7-47,41.6-94,62.4-141,17.3-39,34.2-78.2,51.9-117,8.7-18.8-1.8-38.7-18.1-37.4-8.8.6-17.6.1-26.3.1h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #5cc45b;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #96c;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #7b9594;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M347.3,272.3c-50.4,84.4-159.2,112.7-244.6,63.8C17.4,286.6-12.4,177.8,35.6,92.4l311.7,179.8Z"/>
|
||||
<path class="cls-2" d="M347.3,87.6C297,3.2,188.1-25.1,102.7,24.3,17.4,73.2-12.4,182.1,35.6,267.5L347.3,87.6Z"/>
|
||||
<path class="cls-3" d="M35.6,92.4c-6,10.6-10.7,21.6-14.3,32.7,0,.3-.2.6-.3.9-.3.9-.6,1.8-.8,2.7-.3,1-.6,1.9-.8,2.9,0,.3-.1.5-.2.8-3.4,12.3-5.4,24.9-6.1,37.7,0,0,0,0,0,.1,0,1.3-.1,2.5-.2,3.8,0,.3,0,.7,0,1,0,1,0,1.9,0,2.9,0,.7,0,1.3,0,2,0,.7,0,1.3,0,2,0,1,0,1.9,0,2.9,0,.3,0,.7,0,1,0,1.2,0,2.5.2,3.7,0,0,0,.1,0,.2.6,9.9,1.9,19.8,4.1,29.4,0,.2,0,.3.1.5.2,1.1.5,2.2.8,3.3.2.6.3,1.3.5,1.9.2.6.3,1.2.5,1.8,3.8,14,9.3,27.7,16.7,40.9l151.7-87.5L35.6,92.4Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #d7d135;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4, .cls-5 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #8c67d5;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #3d85a4;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #6576bd;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #b29c85;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M168.1,0C74,6.3-.4,84.6.2,179.8h0c0,95.1,74.4,173.4,167.9,179.8V0Z"/>
|
||||
<path class="cls-3" d="M.2,192.1c6.3,94.1,84.6,168.5,179.8,167.9h0c95.1,0,173.4-74.4,179.8-167.9H.2Z"/>
|
||||
<path class="cls-1" d="M.2,167.9C6.5,73.8,84.8-.6,180,0h0C275.1,0,353.4,74.4,359.7,167.9H.2Z"/>
|
||||
<path class="cls-5" d="M168.1,167.9V.4c-19.4,1.2-37.9,5.4-55.1,12.3-.2,0-.4.2-.7.3-2.1.8-4.1,1.7-6.2,2.7-.7.3-1.4.6-2.1.9-1.6.8-3.2,1.5-4.8,2.3-1,.5-2.1,1-3.1,1.6-1.3.7-2.6,1.4-3.8,2.1-1.2.7-2.5,1.4-3.7,2.1-1.1.6-2.1,1.3-3.2,1.9-1.4.8-2.7,1.7-4,2.5-.9.6-1.9,1.3-2.8,1.9-1.4.9-2.7,1.9-4.1,2.8-.9.7-1.8,1.3-2.7,2-1.3,1-2.6,2-3.9,3-.9.7-1.8,1.5-2.7,2.2-1.2,1-2.4,2-3.6,3.1-.9.8-1.9,1.7-2.8,2.5-1.1,1-2.2,2-3.3,3-1,.9-1.9,1.9-2.9,2.9-1,1-1.9,1.9-2.9,2.9-1,1.1-2,2.2-3,3.3-.8.9-1.7,1.8-2.5,2.8-1,1.2-2.1,2.4-3.1,3.6-.7.9-1.5,1.8-2.2,2.7-1,1.3-2,2.6-3,3.9-.7.9-1.3,1.8-2,2.7-1,1.3-1.9,2.7-2.8,4.1-.6.9-1.3,1.9-1.9,2.8-.9,1.3-1.7,2.7-2.5,4-.7,1.1-1.3,2.1-1.9,3.2-.7,1.2-1.4,2.5-2.1,3.7-.7,1.3-1.4,2.5-2.1,3.8-.5,1-1.1,2-1.6,3.1-.8,1.6-1.6,3.2-2.3,4.8-.3.7-.6,1.4-.9,2.1-.9,2-1.8,4.1-2.7,6.2,0,.2-.2.4-.3.7-6.9,17.2-11.1,35.8-12.3,55.1h167.6Z"/>
|
||||
<path class="cls-4" d="M168.1,359.5v-167.5H.7c6.1,89.5,78,161.4,167.5,167.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ff79b7;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #7b50c4;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #0094c8;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M14.1,74.6c3.7,8.2,7.5,16.9,11.1,25.1s9.9,12.2,20.5,12c19.1-.4,37.6,0,56.9-.1,76.1-.1,152.2-.2,228.2.2,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2-85.9.4-129.1-.1-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
<path class="cls-1" d="M63.1,188.9c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,86.6-.4,162.6,0,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-103.5.1-155.4.3-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
<path class="cls-3" d="M116.1,305.2c3.7,8.2,7.5,16.9,11.1,25.1,3.7,8.2,9.9,12.2,20.5,12,19.1-.4,4.8-.1,24.1-.2,76.1-.1,16.9-.5,92.9-.1,18.9.1,34.1-15.1,26.8-31.6l-22.1-49.8c-7.3-16.5-10.3-12-21.1-12.2-51.9.2-33.7.3-85.7.4-43.1-.2,12.5.6-30.7,0-20.7-.2-34.9,17.3-27.2,31.7,4.4,7.9,7.1,16.1,10.8,24.3h0l.5.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,18 +3,23 @@
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #f6398a;
|
||||
fill: #007e7d;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #007e7d;
|
||||
fill: #7b5c84;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #f6398a;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5c-17.5-20.3-34.9-40.6-52.4-61-9.6-11.2-19.3-22.4-28.9-33.6-7-8.1-13.3-17.2-23.1-22.1-11.2-5.6-24.3-8.1-36.7-8.1C32.8,17.9-10.8,83.4,2.3,144.7c6.5,30.6,28.4,55.4,45.8,79.4,22.2,30.7,65.6,74.1,96.5,103.8,20.2,19.4,50.6,19.4,70.7,0,30.9-29.7,74.3-73.2,96.5-103.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
<path class="cls-2" d="M267.9,17.5c-30.1-.2-56.9,15.4-73.6,39.6l58.6,68.2c4.7,5.5,4.1,13.7-1.4,18.4-2.5,2.1-5.5,3.1-8.5,3.1s-7.3-1.5-9.9-4.5l75.1,86.7c1.3-1.6,2.5-3.3,3.6-4.8,17.4-24,39.3-48.8,45.8-79.4,13-61.3-30.6-126.9-89.8-127.2Z"/>
|
||||
<path class="cls-1" d="M357,144.9c13.6-60.9-29.8-126.9-89-126.9s-56.6,15.3-73.7,39.6l127.6,152.5c14.8-19.8,30-40.8,35.1-65.2Z"/>
|
||||
<path class="cls-3" d="M180.7,81.5c-9.8-11.1-19.2-22.6-29-33.6-6.8-8.1-13.2-17-23-22.1-10.6-5.1-23.9-7.7-36.2-7.7-59.1-.1-102.6,65.9-89.8,126.8,6.4,30.7,28.5,55.4,45.6,79.2,22.1,30.7,65.6,74.1,96.3,103.5,20,19.2,50.7,19.2,70.7,0,28.7-27.5,67.6-66.7,90.8-96.3-44-52.6-112.7-135.1-125.3-149.8h-.1Z"/>
|
||||
<path class="cls-2" d="M234.3,143.1c-.4-.3-.8-.6-1.1-1-17.5-20.4-34.9-40.5-52.4-60.9-.2-.3-.4-.5-.7-.8-.4.4-.7.9-1.1,1.3-17.5,20.4-34.9,40.5-52.4,60.9-.3.4-.8.7-1.1,1l-73.5,85.1c23,30.3,63.5,70.8,92.6,98.6,20,19.2,50.7,19.2,70.7,0,29.6-28.3,69.8-69,92.8-98.9l-73.7-85.3h0Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -31,10 +31,10 @@
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-4" x="0" y="11.7" width="104.6" height="334.3" rx="52.3" ry="52.3"/>
|
||||
<rect class="cls-1" x="251.7" y="126.2" width="105.8" height="219.8" rx="52.9" ry="52.9"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7C22.7,126.2,0,150.1,0,178.6s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6Z"/>
|
||||
<path class="cls-4" d="M52.3,11.7h0c28.9,0,52.3,23.4,52.3,52.3v229.7c0,28.9-23.4,52.3-52.3,52.3h0c-28.9,0-52.3-23.4-52.3-52.3V64C0,35.1,23.4,11.7,52.3,11.7Z"/>
|
||||
<path class="cls-1" d="M304.6,126.2h0c29.2,0,52.9,23.7,52.9,52.9v114c0,29.2-23.7,52.9-52.9,52.9h0c-29.2,0-52.9-23.7-52.9-52.9v-114c0-29.2,23.7-52.9,52.9-52.9Z"/>
|
||||
<path class="cls-6" d="M304.6,126.2H51.7c-29,0-51.7,23.9-51.7,52.4s23.3,52.3,52.3,52.3h252.9c29.1,0,52.3-23.3,52.3-52.3s-23.3-52.3-52.3-52.3h-.6,0Z"/>
|
||||
<circle class="cls-3" cx="304.1" cy="64" r="52.3"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9C25,126.8,3.5,146.6,0,172.2Z"/>
|
||||
<path class="cls-2" d="M251.7,178.6v52.3h52.9c26.7,0,48.3-19.8,51.7-45.3v-7c0-29.1-23.3-52.3-52.3-52.3s-52.3,23.3-52.3,52.3h0Z"/>
|
||||
<path class="cls-5" d="M0,172.2v14c3.5,25.6,25,45.3,51.7,45.3h52.9v-104.6h-52.9c-26.7,0-48.2,19.7-51.7,45.3Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #c1c93d;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #3dc3a4;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #2a4372;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<circle class="cls-3" cx="259.2" cy="76.3" r="76.3"/>
|
||||
<path class="cls-1" d="M168.8,360v-189.2c-79.5,0-144.3,64.7-144.3,144.3v45h144.3Z"/>
|
||||
<path class="cls-2" d="M187.1,170.8v189.2c79.5,0,144.3-64.7,144.3-144.3v-45h-144.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 638 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #a8ca38;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #678e9c;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #2551ff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-1" x="32" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-74.5 84.1) rotate(-30)"/>
|
||||
<rect class="cls-3" x="152.6" y="36.1" width="175.5" height="290.1" rx="87.7" ry="87.7" transform="translate(-58.4 144.4) rotate(-30)"/>
|
||||
<path class="cls-2" d="M167.1,87.6c-4.4-7.5-9.7-14.2-15.7-19.9-28.9,27.3-36.4,71.7-15.7,107.6l57.3,99.2c4.4,7.5,9.7,14.2,15.7,19.9,28.9-27.3,36.4-71.7,15.7-107.6l-57.3-99.2Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #db54d9;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #c0d343;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #009e46;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M126.3,60.6c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8C49.4,164.2,26.1,217.5,2.2,270.5c-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
|
||||
<path class="cls-2" d="M238.2,60.5c-6.9,0-14,0-20.9,0-6.8,0-11.5,3.3-14.9,10.6-5.8,13.3-11.7,26.5-17.6,39.8-23.6,53.2-46.9,106.5-70.8,159.5-5.9,13,.2,28.6,13.7,28.4h41.6c13.8,0,11.7-3.5,14.9-11.1,15.9-36.2,32-72.4,48-108.6,13.3-30,26.3-60.2,40-90.1,6.7-14.5-1.4-29.8-13.9-28.8-6.8.5-13.5,0-20.2,0h0Z"/>
|
||||
<circle class="cls-3" cx="300.2" cy="239.1" r="59.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #f48725;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #1797c8;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-2" d="M155.3,1.1C72.9,1.1,6,68,6,150.4s66.9,149.3,149.3,149.3h8.9V1.1h-8.9Z"/>
|
||||
<path class="cls-1" d="M205.7,61.8h-8.9v298.2h8.9c82.4,0,149.3-66.9,149.3-149.3S288.2,61.4,205.7,61.8Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #33669a;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #ffd05a;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #f16667;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #319966;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-4" d="M265.6,180v1.3h95.9C361.4,81.2,280.2,0,180.1,0v94.5c47.2,0,85.5,38.3,85.5,85.5Z"/>
|
||||
<path class="cls-3" d="M94.6,180v-1.3H-1.2c0,100.1,81.2,181.3,181.3,181.3v-94.5c-47.2,0-85.5-38.3-85.5-85.5Z"/>
|
||||
<path class="cls-1" d="M180.1,0C80,0-1.2,81.2-1.2,181.3h181.3V0Z"/>
|
||||
<path class="cls-2" d="M180.1,360c100.1,0,181.3-81.2,181.3-181.3h-181.3v181.3h0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 839 B |
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #5896fa;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #734dc6;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #c150a4;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-3" d="M123,.4h224.4c0,60.8-49.5,110.3-110.3,110.3H12.6C12.6,49.9,62.1.4,123,.4Z"/>
|
||||
<path class="cls-2" d="M347.4,249.3H123c-60.8,0-110.3,49.5-110.3,110.3h224.4c60.8,0,110.3-49.5,110.3-110.3h0Z"/>
|
||||
<path class="cls-1" d="M237,124.8H12.6c0,60.8,49.5,110.3,110.3,110.3h224.4c0-60.8-49.5-110.3-110.3-110.3Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 734 B |
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #00997d;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #ffa34d;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #0776be;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #5051df;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-4" d="M340.5,213.3l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
|
||||
<path class="cls-1" d="M18.9,305l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9l-110.8-110.3c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
|
||||
<path class="cls-3" d="M340.5,54.5l-7.7-7.7c-10.4-7.1-23-11-36.2-11h-157.5l110.3,110.3c25.2,25.2,66.4,25.2,91.6,0,25.2-25.2,25.2-66.4,0-91.6h-.5Z"/>
|
||||
<path class="cls-2" d="M18.9,146.2l4.9,4.9c11,8.8,24.7,13.7,40.1,13.7h156.9L110,54.6c-25.2-24.7-65.9-24.7-91.1,0-25.2,24.7-25.2,66.4,0,91.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 360 360">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #33669a;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2, .cls-3, .cls-4 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #ffd05a;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #f16667;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #319966;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<rect class="cls-3" x=".2" y="51.2" width="147.8" height="84.5" rx="42.1" ry="42.1"/>
|
||||
<path class="cls-2" d="M105.6,51.2h0c23.4,0,42.4,19.1,42.4,42.7v172.2c0,23.6-19,42.7-42.4,42.7h0c-23.4,0-42.4-19.1-42.4-42.7V93.9c0-23.6,19-42.7,42.4-42.7Z"/>
|
||||
<path class="cls-4" d="M232.7,56.9h0c20.3,11.7,27.2,37.7,15.5,57.9l-105.9,172.7c-11.7,20.3-37.7,27.2-57.9,15.5h0c-20.3-11.7-27.2-37.7-15.5-57.9l105.9-172.7c11.7-20.3,37.7-27.2,57.9-15.5h0Z"/>
|
||||
<path class="cls-1" d="M211.5,51.2h0c23.4,0,42.4,19.1,42.4,42.7v172.2c0,23.6-19,42.7-42.4,42.7h0c-23.4,0-42.4-19.1-42.4-42.7V93.9c0-23.6,19-42.7,42.4-42.7Z"/>
|
||||
<path class="cls-3" d="M338.6,56.9h0c20.3,11.7,27.2,37.7,15.5,57.9l-105.9,172.7c-11.7,20.3-37.7,27.2-57.9,15.5h0c-20.3-11.7-27.2-37.7-15.5-57.9l105.9-172.7c11.7-20.3,37.7-27.2,57.9-15.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -68,3 +68,76 @@
|
||||
filter: brightness(0.92);
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-stats-card {
|
||||
width: max-content;
|
||||
min-width: 9rem;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.mobile-stats-card {
|
||||
width: max-content;
|
||||
min-width: 9rem;
|
||||
max-width: none;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ladill app launcher — cap at 5 rows (15 apps), always-visible scroll indicator */
|
||||
.ladill-launcher-apps-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps {
|
||||
max-height: calc(5 * 5.25rem + 4 * 0.25rem);
|
||||
overflow-y: scroll;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(148 163 184) rgb(241 245 249);
|
||||
margin-right: -0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-track {
|
||||
background: rgb(241 245 249);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(148 163 184);
|
||||
border-radius: 9999px;
|
||||
min-height: 2.5rem;
|
||||
border: 2px solid rgb(241 245 249);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.ladill-launcher-apps::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(100 116 139);
|
||||
}
|
||||
|
||||
.ladill-launcher-scroll-more {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0.125rem;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
-webkit-transform: translateX(-50%);
|
||||
transform: translateX(-50%);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: rgb(255 255 255);
|
||||
color: rgb(100 116 139);
|
||||
box-shadow: 0 1px 2px rgb(15 23 42 / 0.08);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import './bootstrap';
|
||||
|
||||
// Registers window.hostingInteractiveTerminal (xterm.js) used by the control-panel
|
||||
// terminal blade. Must stay imported so clean builds include the terminal.
|
||||
import './hosting-terminal';
|
||||
|
||||
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
|
||||
import { registerLadillSearchShortcut } from './ladill-search-shortcut';
|
||||
import { registerLadillDomainPurchase } from './ladill-domain-purchase';
|
||||
|
||||
registerLadillModalHelpers();
|
||||
registerLadillSearchShortcut();
|
||||
registerLadillDomainPurchase();
|
||||
|
||||
|
||||
import Alpine from 'alpinejs';
|
||||
import { registerLadillClipboard } from './ladill-clipboard';
|
||||
import collapse from '@alpinejs/collapse';
|
||||
|
||||
Alpine.plugin(collapse);
|
||||
registerLadillClipboard(Alpine);
|
||||
|
||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||
open: false,
|
||||
@@ -191,6 +201,26 @@ Alpine.data('topbarSearch', (config = {}) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
// Wallet balance peek for the avatar dropdown.
|
||||
Alpine.data('walletWidget', (config = {}) => ({
|
||||
display: '…',
|
||||
async load() {
|
||||
if (! config.url) {
|
||||
this.display = 'View wallet';
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(config.url, { headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await res.json();
|
||||
this.display = data.available ? data.formatted : 'View wallet';
|
||||
} catch (e) {
|
||||
this.display = 'View wallet';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
window.Alpine = Alpine;
|
||||
registerLadillConfirmStore(Alpine);
|
||||
|
||||
|
||||
@@ -0,0 +1,608 @@
|
||||
// Browser terminal (xterm.js) Alpine factory for the hosting control panel.
|
||||
// Exposed as a global so the panel blade can use x-data="hostingInteractiveTerminal({...})".
|
||||
// NOTE: this frontend previously only existed in an out-of-band build and was
|
||||
// lost on clean CI rebuilds — keep it committed.
|
||||
import { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
|
||||
window.hostingInteractiveTerminal = (config = {}) => {
|
||||
console.log('[Terminal] hostingInteractiveTerminal called with config:', config);
|
||||
return {
|
||||
sessionId: null,
|
||||
offset: 0,
|
||||
term: null,
|
||||
fitAddon: null,
|
||||
pollTimer: null,
|
||||
resizeTimer: null,
|
||||
inputTimer: null,
|
||||
pendingInput: '',
|
||||
commandLine: '',
|
||||
overlayMessage: 'Connecting to shell...',
|
||||
canReconnect: false,
|
||||
statusLabel: 'Connecting',
|
||||
terminalActive: false,
|
||||
termHasOutput: false,
|
||||
currentPath: config.initialPath ?? '/public_html',
|
||||
currentPathLabel: `Starting in ${config.initialPath === '/' ? `/home/${config.username ?? ''}` : `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`}`,
|
||||
// Full working-directory path shown in the toolbar; updated live from the
|
||||
// shell's OSC 7 cwd report as the user navigates.
|
||||
fullPath: config.initialPath === '/'
|
||||
? `/home/${config.username ?? ''}`
|
||||
: `/home/${config.username ?? ''}${config.initialPath ?? '/public_html'}`,
|
||||
promptPath() {
|
||||
if (this.currentPath === '/' || !this.currentPath) {
|
||||
return '~';
|
||||
}
|
||||
|
||||
return `~${this.currentPath}`;
|
||||
},
|
||||
writePrompt(command = '') {
|
||||
const prompt = `\x1b[36m${config.username ?? 'user'}@ladill:${this.promptPath()}$ \x1b[0m`;
|
||||
const suffix = command ? `${command}\r\n` : '';
|
||||
this.term?.write(`${prompt}${suffix}`);
|
||||
},
|
||||
|
||||
init() {
|
||||
this.term = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'bar',
|
||||
convertEol: true,
|
||||
allowTransparency: true,
|
||||
disableStdin: false,
|
||||
fontFamily: '"JetBrains Mono", "SF Mono", "Fira Code", "Monaco", "Consolas", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
letterSpacing: 0,
|
||||
theme: {
|
||||
background: '#0a0a0a',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#a1a1aa',
|
||||
cursorAccent: '#0a0a0a',
|
||||
selectionBackground: 'rgba(161, 161, 170, 0.2)',
|
||||
selectionForeground: '#fafafa',
|
||||
black: '#18181b',
|
||||
red: '#f87171',
|
||||
green: '#4ade80',
|
||||
yellow: '#fbbf24',
|
||||
blue: '#60a5fa',
|
||||
magenta: '#c084fc',
|
||||
cyan: '#22d3ee',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#52525b',
|
||||
brightRed: '#fca5a5',
|
||||
brightGreen: '#86efac',
|
||||
brightYellow: '#fde047',
|
||||
brightBlue: '#93c5fd',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightCyan: '#67e8f9',
|
||||
brightWhite: '#fafafa',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[Terminal] Initializing terminal...');
|
||||
this.fitAddon = new FitAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
this.term.open(this.$refs.terminal);
|
||||
// The shell reports its working directory via OSC 7 (file://host/path);
|
||||
// capture it so the toolbar shows the live full path as the user cd's.
|
||||
this.term.parser?.registerOscHandler(7, (data) => {
|
||||
try {
|
||||
let p = String(data || '').replace(/^file:\/\/[^/]*/, '');
|
||||
p = decodeURIComponent(p);
|
||||
if (p) {
|
||||
this.fullPath = p.replace(/\/$/, '') || '/';
|
||||
}
|
||||
} catch (e) { /* ignore malformed cwd reports */ }
|
||||
return true;
|
||||
});
|
||||
this.term.onData((data) => {
|
||||
if (this.sessionId) {
|
||||
this.queueInput(data);
|
||||
}
|
||||
});
|
||||
|
||||
// Delay initial fit to ensure container has dimensions
|
||||
requestAnimationFrame(() => {
|
||||
this.fitAddon.fit();
|
||||
this.bindTerminalInputCapture();
|
||||
this.term.focus();
|
||||
});
|
||||
|
||||
// Use ResizeObserver for more reliable resize detection
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => this.queueResize());
|
||||
this.resizeObserver.observe(this.$refs.terminal);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', () => this.queueResize());
|
||||
window.addEventListener('beforeunload', () => this.shutdown(true));
|
||||
|
||||
this.start();
|
||||
},
|
||||
|
||||
async start() {
|
||||
this.statusLabel = 'Connecting';
|
||||
this.overlayMessage = 'Connecting to shell...';
|
||||
this.canReconnect = false;
|
||||
|
||||
try {
|
||||
const response = await fetch(config.startUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: config.initialPath ?? '/public_html',
|
||||
cols: this.term.cols,
|
||||
rows: this.term.rows,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok || !payload.session_id) {
|
||||
throw new Error(payload.message || 'Unable to start the browser terminal session.');
|
||||
}
|
||||
|
||||
if (payload.status === 'failed') {
|
||||
throw new Error(payload.error || 'Unable to start the browser terminal session.');
|
||||
}
|
||||
|
||||
this.sessionId = payload.session_id;
|
||||
this.offset = 0;
|
||||
this.statusLabel = payload.status === 'starting' ? 'Connecting' : 'Live';
|
||||
this.overlayMessage = payload.status === 'starting' ? 'Connecting to shell...' : '';
|
||||
this.canReconnect = false;
|
||||
this.queueResize();
|
||||
this.poll();
|
||||
// Delay focus to ensure terminal is fully rendered
|
||||
setTimeout(() => this.focusTerminal(), 100);
|
||||
} catch (error) {
|
||||
this.statusLabel = 'Unavailable';
|
||||
this.overlayMessage = error.message || 'Unable to connect to the browser terminal.';
|
||||
this.canReconnect = true;
|
||||
}
|
||||
},
|
||||
|
||||
buildUrl(template) {
|
||||
return String(template || '').replace('__SESSION__', this.sessionId ?? '');
|
||||
},
|
||||
|
||||
bindTerminalInputCapture() {
|
||||
console.log('[Terminal] bindTerminalInputCapture called');
|
||||
const terminalElement = this.$refs.terminal;
|
||||
|
||||
if (!terminalElement || terminalElement.dataset.keyboardCaptureBound === 'true') {
|
||||
console.log('[Terminal] Already bound or no element');
|
||||
return;
|
||||
}
|
||||
|
||||
this.boundTerminalKeydownHandler = (event) => this.handleTerminalKeydown(event);
|
||||
this.boundTerminalPasteHandler = (event) => this.handleTerminalPaste(event);
|
||||
this.boundTerminalPointerHandler = (event) => {
|
||||
this.terminalActive = this.$refs.terminal?.contains(event.target) ?? false;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', this.boundTerminalKeydownHandler, true);
|
||||
window.addEventListener('paste', this.boundTerminalPasteHandler, true);
|
||||
window.addEventListener('pointerdown', this.boundTerminalPointerHandler, true);
|
||||
terminalElement.dataset.keyboardCaptureBound = 'true';
|
||||
console.log('[Terminal] Input capture bound successfully');
|
||||
},
|
||||
|
||||
queueInput(data) {
|
||||
if (!this.sessionId || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingInput += data;
|
||||
|
||||
if (this.inputTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputTimer = window.setTimeout(() => this.flushInput(), 16);
|
||||
},
|
||||
|
||||
async flushInput() {
|
||||
const payload = this.pendingInput;
|
||||
this.pendingInput = '';
|
||||
this.inputTimer = null;
|
||||
|
||||
if (!this.sessionId || payload === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Terminal] Sending input:', JSON.stringify(payload));
|
||||
|
||||
try {
|
||||
const response = await fetch(this.buildUrl(config.inputUrlTemplate), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain;charset=UTF-8',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: payload,
|
||||
});
|
||||
console.log('[Terminal] Input response:', response.status);
|
||||
} catch (e) {
|
||||
console.error('[Terminal] Input error:', e);
|
||||
this.term?.write('\r\n[Input delivery failed]\r\n');
|
||||
}
|
||||
},
|
||||
|
||||
handleTerminalKeydown(event) {
|
||||
console.log('[Terminal] keydown:', event.key, 'terminalActive:', this.terminalActive, 'sessionId:', !!this.sessionId);
|
||||
|
||||
if (!this.shouldCaptureTerminalInput(event)) {
|
||||
console.log('[Terminal] Not capturing input');
|
||||
return;
|
||||
}
|
||||
|
||||
const sequence = this.keyEventSequence(event);
|
||||
console.log('[Terminal] sequence:', sequence ? JSON.stringify(sequence) : 'null');
|
||||
|
||||
if (sequence === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
this.queueInput(sequence);
|
||||
},
|
||||
|
||||
handleTerminalPaste(event) {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalElement = this.$refs.terminal;
|
||||
const target = event.target;
|
||||
const inTerminal = Boolean(
|
||||
terminalElement
|
||||
&& target instanceof Node
|
||||
&& (terminalElement === target || terminalElement.contains(target))
|
||||
);
|
||||
const termFocused = Boolean(
|
||||
this.term?.textarea && document.activeElement === this.term.textarea
|
||||
);
|
||||
|
||||
// Panel paste often targets the wrapper/div, not the xterm textarea.
|
||||
if (!this.terminalActive && !inTerminal && !termFocused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||
|
||||
if (text === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.terminalActive = true;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation?.();
|
||||
|
||||
// Bracketed paste keeps bash/readline from mangling long pasted commands.
|
||||
const normalized = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/\n/g, '\r');
|
||||
this.queueInput(`\x1b[200~${normalized}\x1b[201~`);
|
||||
},
|
||||
|
||||
shouldCaptureTerminalInput(event) {
|
||||
if (!this.sessionId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
const terminalElement = this.$refs.terminal;
|
||||
|
||||
if (terminalElement && target instanceof Node && terminalElement.contains(target)) {
|
||||
this.terminalActive = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.term?.textarea && document.activeElement === this.term.textarea) {
|
||||
this.terminalActive = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!this.terminalActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !target.closest('input, textarea, select, button, [contenteditable="true"], a[href]');
|
||||
},
|
||||
|
||||
keyEventSequence(event) {
|
||||
if (!this.sessionId || event.metaKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.ctrlKey && !event.altKey) {
|
||||
const key = String(event.key || '').toLowerCase();
|
||||
|
||||
if (key >= 'a' && key <= 'z') {
|
||||
return String.fromCharCode(key.charCodeAt(0) - 96);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case ' ':
|
||||
case '@':
|
||||
return '\x00';
|
||||
case '[':
|
||||
return '\x1b';
|
||||
case '\\':
|
||||
return '\x1c';
|
||||
case ']':
|
||||
return '\x1d';
|
||||
case '^':
|
||||
return '\x1e';
|
||||
case '_':
|
||||
return '\x1f';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.altKey && !event.ctrlKey && String(event.key || '').length === 1) {
|
||||
return `\x1b${event.key}`;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'Enter':
|
||||
return '\r';
|
||||
case 'Backspace':
|
||||
return '\x7f';
|
||||
case 'Tab':
|
||||
return event.shiftKey ? '\x1b[Z' : '\t';
|
||||
case 'Escape':
|
||||
return '\x1b';
|
||||
case 'ArrowUp':
|
||||
return '\x1b[A';
|
||||
case 'ArrowDown':
|
||||
return '\x1b[B';
|
||||
case 'ArrowRight':
|
||||
return '\x1b[C';
|
||||
case 'ArrowLeft':
|
||||
return '\x1b[D';
|
||||
case 'Home':
|
||||
return '\x1b[H';
|
||||
case 'End':
|
||||
return '\x1b[F';
|
||||
case 'Insert':
|
||||
return '\x1b[2~';
|
||||
case 'Delete':
|
||||
return '\x1b[3~';
|
||||
case 'PageUp':
|
||||
return '\x1b[5~';
|
||||
case 'PageDown':
|
||||
return '\x1b[6~';
|
||||
default:
|
||||
return event.ctrlKey || event.altKey || String(event.key || '').length !== 1
|
||||
? null
|
||||
: event.key;
|
||||
}
|
||||
},
|
||||
|
||||
poll() {
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(this.buildUrl(config.readUrlTemplate), window.location.origin);
|
||||
url.searchParams.set('offset', String(this.offset));
|
||||
|
||||
fetch(url.toString(), {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
})
|
||||
.then(async (response) => {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
console.log('[Terminal] Poll response:', response.status, 'output length:', payload.output?.length ?? 0, 'status:', payload.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Unable to read terminal output.');
|
||||
}
|
||||
|
||||
if (typeof payload.output === 'string' && payload.output.length > 0) {
|
||||
console.log('[Terminal] Writing output:', JSON.stringify(payload.output.substring(0, 100)));
|
||||
this.term?.write(payload.output);
|
||||
this.termHasOutput = true;
|
||||
this.overlayMessage = '';
|
||||
this.statusLabel = 'Live';
|
||||
}
|
||||
|
||||
if (typeof payload.offset === 'number') {
|
||||
this.offset = payload.offset;
|
||||
}
|
||||
|
||||
if (payload.status === 'starting') {
|
||||
if (!this.termHasOutput) {
|
||||
this.statusLabel = 'Connecting';
|
||||
this.overlayMessage = 'Connecting to shell...';
|
||||
}
|
||||
this.pollTimer = window.setTimeout(() => this.poll(), 120);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.status === 'closed' || payload.status === 'failed') {
|
||||
this.statusLabel = payload.status === 'failed' ? 'Failed' : 'Closed';
|
||||
this.overlayMessage = payload.error || 'Shell session ended.';
|
||||
this.canReconnect = true;
|
||||
this.sessionId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.statusLabel = 'Live';
|
||||
this.pollTimer = window.setTimeout(() => this.poll(), 120);
|
||||
})
|
||||
.catch((error) => {
|
||||
this.statusLabel = 'Disconnected';
|
||||
this.overlayMessage = error.message || 'Terminal connection lost.';
|
||||
this.canReconnect = true;
|
||||
this.sessionId = null;
|
||||
});
|
||||
},
|
||||
|
||||
queueResize() {
|
||||
if (!this.term || !this.fitAddon) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.clearTimeout(this.resizeTimer);
|
||||
this.resizeTimer = window.setTimeout(() => {
|
||||
this.fitAddon.fit();
|
||||
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(this.buildUrl(config.resizeUrlTemplate), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cols: this.term.cols,
|
||||
rows: this.term.rows,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
}, 120);
|
||||
},
|
||||
|
||||
async sendCommand(command) {
|
||||
const value = String(command || '').trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commandLine = '';
|
||||
this.focusCommandInput();
|
||||
|
||||
this.term?.write('\r\n');
|
||||
this.writePrompt(value);
|
||||
|
||||
try {
|
||||
const response = await fetch(config.runUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
command: value,
|
||||
path: this.currentPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
const output = typeof payload.output === 'string'
|
||||
? payload.output
|
||||
: (typeof payload.error === 'string' ? payload.error : '');
|
||||
|
||||
if (typeof payload.cwd === 'string' && payload.cwd.length > 0) {
|
||||
this.currentPath = payload.cwd;
|
||||
this.currentPathLabel = this.currentPath === '/'
|
||||
? `/home/${config.username ?? ''}`
|
||||
: `/home/${config.username ?? ''}${this.currentPath}`;
|
||||
}
|
||||
|
||||
if (payload.clear) {
|
||||
this.term?.reset();
|
||||
}
|
||||
|
||||
if (output) {
|
||||
this.term?.write(`${output.replace(/\n/g, '\r\n')}\r\n`);
|
||||
}
|
||||
|
||||
if (!response.ok && !output) {
|
||||
this.term?.write('\x1b[31mCommand failed.\x1b[0m\r\n');
|
||||
}
|
||||
|
||||
this.writePrompt();
|
||||
} catch (error) {
|
||||
this.term?.write(`\x1b[31m${error.message || 'Command failed.'}\x1b[0m\r\n`);
|
||||
this.writePrompt();
|
||||
}
|
||||
},
|
||||
|
||||
submitCommand() {
|
||||
const command = String(this.commandLine || '').trim();
|
||||
if (!command) {
|
||||
this.term?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendCommand(command);
|
||||
},
|
||||
|
||||
async restart() {
|
||||
this.shutdown();
|
||||
this.term?.reset();
|
||||
this.pendingInput = '';
|
||||
this.commandLine = '';
|
||||
this.offset = 0;
|
||||
this.terminalActive = false;
|
||||
this.termHasOutput = false;
|
||||
await this.start();
|
||||
},
|
||||
|
||||
focusTerminal() {
|
||||
console.log('[Terminal] focusTerminal called');
|
||||
if (this.term) {
|
||||
this.terminalActive = true;
|
||||
this.bindTerminalInputCapture();
|
||||
this.$refs.terminal?.focus();
|
||||
this.term.focus();
|
||||
console.log('[Terminal] terminalActive set to true');
|
||||
}
|
||||
},
|
||||
|
||||
focusCommandInput() {
|
||||
this.$nextTick(() => this.$refs.commandInput?.focus());
|
||||
},
|
||||
|
||||
shutdown(keepalive = false) {
|
||||
if (this.pollTimer) {
|
||||
window.clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
|
||||
if (!this.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = this.sessionId;
|
||||
this.sessionId = null;
|
||||
this.terminalActive = false;
|
||||
|
||||
fetch(String(config.closeUrlTemplate || '').replace('__SESSION__', sessionId), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-CSRF-TOKEN': config.csrfToken,
|
||||
},
|
||||
keepalive,
|
||||
}).catch(() => {});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const COPY_FEEDBACK_MS = 2000;
|
||||
|
||||
export async function writeClipboardText(text) {
|
||||
const value = String(text ?? '');
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy copy
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLadillClipboard(Alpine) {
|
||||
Alpine.data('copyButton', (text = '') => ({
|
||||
copied: false,
|
||||
text: text ?? '',
|
||||
resetTimer: null,
|
||||
async copy() {
|
||||
const ok = await writeClipboardText(this.text);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.copied = true;
|
||||
|
||||
if (this.resetTimer) {
|
||||
clearTimeout(this.resetTimer);
|
||||
}
|
||||
|
||||
this.resetTimer = setTimeout(() => {
|
||||
this.copied = false;
|
||||
}, COPY_FEEDBACK_MS);
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { closeLadillModal, openLadillModal } from './ladill-modals';
|
||||
|
||||
const PURCHASE_MODAL = 'domain-purchase';
|
||||
|
||||
function allowedParentOrigin(origin) {
|
||||
try {
|
||||
const host = new URL(origin).hostname;
|
||||
|
||||
return host === 'ladill.com' || host.endsWith('.ladill.com');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadPurchaseFrame() {
|
||||
const frame = document.getElementById('ladill-domain-purchase-frame');
|
||||
if (!frame || frame.dataset.loaded === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const embedUrl = frame.dataset.embedUrl;
|
||||
if (embedUrl) {
|
||||
frame.src = embedUrl;
|
||||
frame.dataset.loaded = '1';
|
||||
}
|
||||
}
|
||||
|
||||
export function registerLadillDomainPurchase() {
|
||||
window.ladillDomainSourceForm = (initial = {}) => ({
|
||||
domainSource: initial.domainSource ?? 'external',
|
||||
selectedOwnedDomain: initial.selectedOwnedDomain ?? '',
|
||||
externalDomain: initial.externalDomain ?? '',
|
||||
ownedDomains: initial.ownedDomains ?? [],
|
||||
ownedUrl: initial.ownedUrl ?? '',
|
||||
onboardingMode: initial.onboardingMode ?? 'ns_auto',
|
||||
|
||||
openPurchaseModal() {
|
||||
loadPurchaseFrame();
|
||||
openLadillModal(PURCHASE_MODAL);
|
||||
},
|
||||
|
||||
async refreshOwnedDomains() {
|
||||
if (!this.ownedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(this.ownedUrl, {
|
||||
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await res.json();
|
||||
this.ownedDomains = Array.isArray(payload.data) ? payload.data : [];
|
||||
this.domainSource = 'ladill';
|
||||
} catch {
|
||||
// Non-fatal.
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (!allowedParentOrigin(event.origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = event.data;
|
||||
if (!data || data.type !== 'ladill:domains:purchase-complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
closeLadillModal(PURCHASE_MODAL);
|
||||
|
||||
const domains = Array.isArray(data.domains) ? data.domains : (data.domain ? [data.domain] : []);
|
||||
document.querySelectorAll('[x-data*="ladillDomainSourceForm"]').forEach((el) => {
|
||||
const component = el._x_dataStack?.[0];
|
||||
if (!component?.refreshOwnedDomains) {
|
||||
return;
|
||||
}
|
||||
|
||||
component.refreshOwnedDomains().then(() => {
|
||||
const newest = domains.map((d) => String(d).toLowerCase()).find(Boolean);
|
||||
if (newest) {
|
||||
component.selectedOwnedDomain = newest;
|
||||
component.domainSource = 'ladill';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('open-modal', (event) => {
|
||||
if (String(event.detail) === PURCHASE_MODAL) {
|
||||
loadPurchaseFrame();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
function isEditableTarget(element) {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tag = element.tagName;
|
||||
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return element.isContentEditable;
|
||||
}
|
||||
|
||||
function isVisibleInput(input) {
|
||||
if (!(input instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const style = window.getComputedStyle(input);
|
||||
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& style.opacity !== '0';
|
||||
}
|
||||
|
||||
function findSearchInput() {
|
||||
const marked = [...document.querySelectorAll('[data-ladill-search-input]')];
|
||||
const visibleMarked = marked.find(isVisibleInput);
|
||||
|
||||
if (visibleMarked) {
|
||||
return visibleMarked;
|
||||
}
|
||||
|
||||
if (marked.length > 0) {
|
||||
return marked[0];
|
||||
}
|
||||
|
||||
return document.querySelector('[x-data*="topbarSearch"] input');
|
||||
}
|
||||
|
||||
function focusSearchInput() {
|
||||
const input = findSearchInput();
|
||||
|
||||
if (!input) {
|
||||
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
|
||||
|
||||
if (fallbackUrl) {
|
||||
window.location.href = fallbackUrl;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isVisibleInput(input)) {
|
||||
const fallbackUrl = document.body?.dataset?.ladillSearchUrl;
|
||||
|
||||
if (fallbackUrl) {
|
||||
window.location.href = fallbackUrl;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
input.focus();
|
||||
|
||||
if (input instanceof HTMLInputElement && (input.type === 'text' || input.type === 'search')) {
|
||||
input.select();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerLadillSearchShortcut() {
|
||||
if (window.__ladillSearchShortcutRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.__ladillSearchShortcutRegistered = true;
|
||||
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== '/' && event.code !== 'Slash') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditableTarget(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
focusSearchInput();
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,33 @@
|
||||
@props([
|
||||
'href' => null,
|
||||
'type' => 'button',
|
||||
'label' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
$tag = $href ? 'a' : 'button';
|
||||
$ariaLabel = $label ?? trim(preg_replace('/\s+/', ' ', strip_tags((string) $slot)));
|
||||
@endphp
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
{{ $attributes->class(['btn-primary']) }}
|
||||
aria-label="{{ $ariaLabel }}"
|
||||
title="{{ $ariaLabel }}"
|
||||
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
</{{ $tag }}>
|
||||
|
||||
<{{ $tag }}
|
||||
@if ($href) href="{{ $href }}" @endif
|
||||
@if ($tag === 'button') type="{{ $type }}" @endif
|
||||
{{ $attributes->class(['btn-primary hidden lg:inline-flex']) }}
|
||||
>
|
||||
<svg class="h-4 w-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/>
|
||||
</svg>
|
||||
{{ $slot }}
|
||||
</{{ $tag }}>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
@props([
|
||||
'text' => '',
|
||||
'label' => 'Copy',
|
||||
'copiedLabel' => 'Copied!',
|
||||
'icon' => false,
|
||||
'copiedClass' => '',
|
||||
])
|
||||
|
||||
<button
|
||||
type="button"
|
||||
x-data="copyButton(@js($text))"
|
||||
@click="copy()"
|
||||
:title="copied ? @js($copiedLabel) : @js($icon ? 'Copy' : $label)"
|
||||
:class="copied ? @js($copiedClass) : ''"
|
||||
{{ $attributes->class($icon ? 'inline-flex shrink-0 items-center justify-center' : '') }}
|
||||
>
|
||||
@if ($icon)
|
||||
<svg x-show="!copied" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<svg x-show="copied" x-cloak class="h-4 w-4 text-emerald-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
@elseif ($slot->isNotEmpty())
|
||||
{{ $slot }}
|
||||
@else
|
||||
<span x-text="copied ? @js($copiedLabel) : @js($label)"></span>
|
||||
@endif
|
||||
</button>
|
||||
@@ -0,0 +1,11 @@
|
||||
<x-modal name="domain-purchase" maxWidth="2xl" zIndex="z-[60]">
|
||||
<div class="flex flex-col" style="height: min(80vh, 720px);">
|
||||
<div class="flex shrink-0 items-center border-b border-slate-100 px-5 py-4 pr-14">
|
||||
<h2 class="text-base font-semibold text-slate-900">Register a domain</h2>
|
||||
</div>
|
||||
<iframe id="ladill-domain-purchase-frame" title="Ladill Domains"
|
||||
src="about:blank"
|
||||
data-embed-url="{{ ladill_domains_embed_url() }}"
|
||||
class="min-h-0 w-full flex-1 border-0 bg-white"></iframe>
|
||||
</div>
|
||||
</x-modal>
|
||||
@@ -0,0 +1,141 @@
|
||||
@props([
|
||||
'ownedDomains' => [],
|
||||
'fieldName' => 'domain',
|
||||
'ownedUrl' => '',
|
||||
'variant' => 'slate',
|
||||
'showConnectionMethod' => false,
|
||||
'showDocumentRoot' => false,
|
||||
])
|
||||
|
||||
@php
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
|
||||
$oldValue = trim((string) old($fieldName, ''));
|
||||
$initialDomainSource = empty($ownedDomainHosts)
|
||||
? 'external'
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwnedDomain = in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : '';
|
||||
$externalFallbackDomain = ! in_array($oldValue, $ownedDomainHosts, true) ? $oldValue : '';
|
||||
$isGray = $variant === 'gray';
|
||||
$inputClass = $isGray
|
||||
? 'block w-full rounded-lg border-gray-200 bg-white text-sm focus:border-gray-400 focus:ring-0'
|
||||
: 'block w-full rounded-xl border-slate-200 bg-slate-50 text-sm shadow-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500';
|
||||
$toggleWrapClass = $isGray ? 'bg-gray-100' : 'bg-slate-100';
|
||||
$purchaseLinkClass = $isGray
|
||||
? 'font-medium text-gray-700 underline underline-offset-2 hover:text-gray-900'
|
||||
: 'font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800';
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium {{ $isGray ? 'text-gray-700' : 'text-slate-700' }}">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md {{ $toggleWrapClass }} p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
<template x-if="ownedDomains.length">
|
||||
<div class="space-y-2">
|
||||
<select name="{{ $fieldName }}" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'" class="{{ $inputClass }}">
|
||||
<option value="">Choose a domain...</option>
|
||||
<template x-for="domain in ownedDomains" :key="domain">
|
||||
<option :value="domain" x-text="domain"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="text-xs {{ $isGray ? 'text-gray-500' : 'text-slate-500' }}">
|
||||
Prefer another?
|
||||
<button type="button" @click="openPurchaseModal()" class="{{ $purchaseLinkClass }}">Get a new domain</button>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!ownedDomains.length">
|
||||
<div @class([
|
||||
'rounded-xl px-4 py-3 text-center text-sm',
|
||||
'bg-gray-50 text-gray-600' => $isGray,
|
||||
'bg-slate-50 text-slate-600' => ! $isGray,
|
||||
])>
|
||||
No Ladill domains yet.
|
||||
<button type="button" @click="openPurchaseModal()" class="{{ $purchaseLinkClass }}">Register a domain</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input type="text" name="{{ $fieldName }}" x-model="externalDomain" :disabled="domainSource !== 'external'"
|
||||
placeholder="example.com" class="{{ $inputClass }} @error($fieldName) border-rose-300 @enderror">
|
||||
<p class="mt-1.5 text-xs {{ $isGray ? 'text-gray-500' : 'text-slate-500' }}">Enter your domain without www or http (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
@error($fieldName)
|
||||
<p class="mt-1.5 text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
||||
|
||||
@if($showConnectionMethod)
|
||||
<div x-show="domainSource === 'external'" x-cloak class="mt-5">
|
||||
<label class="mb-3 block text-sm font-medium {{ $isGray ? 'text-gray-700' : 'text-slate-700' }}">Connection method</label>
|
||||
<div class="space-y-3">
|
||||
<label @class([
|
||||
'relative flex cursor-pointer rounded-lg border bg-white p-4 transition hover:bg-slate-50',
|
||||
'border-slate-200' => ! $isGray,
|
||||
'border-gray-200 hover:bg-gray-50' => $isGray,
|
||||
])>
|
||||
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Point nameservers</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600"></div>
|
||||
</label>
|
||||
|
||||
<label @class([
|
||||
'relative flex cursor-pointer rounded-lg border bg-white p-4 transition hover:bg-slate-50',
|
||||
'border-slate-200' => ! $isGray,
|
||||
'border-gray-200 hover:bg-gray-50' => $isGray,
|
||||
])>
|
||||
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Add DNS records</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="pointer-events-none absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
@error('onboarding_mode')
|
||||
<p class="mt-1.5 text-sm text-rose-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($showDocumentRoot)
|
||||
<div class="mt-5">
|
||||
<label class="mb-1 block text-sm font-medium text-slate-700">Document root <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
|
||||
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
|
||||
</div>
|
||||
@endif
|
||||
@@ -68,6 +68,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include('partials.confirm-prompt')
|
||||
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
@props([
|
||||
'badge',
|
||||
'title',
|
||||
'description' => null,
|
||||
'stats' => [],
|
||||
])
|
||||
|
||||
<div {{ $attributes->merge(['class' => 'relative overflow-hidden rounded-2xl border border-slate-200 bg-white']) }}>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50/80 via-white to-violet-50/60"></div>
|
||||
<div class="relative px-6 py-8 sm:px-10">
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<div class="inline-flex items-center gap-1.5 rounded-full bg-indigo-100 px-3 py-1 text-xs font-semibold text-indigo-700">
|
||||
{{ $badge }}
|
||||
</div>
|
||||
<h1 class="mt-3 text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">{{ $title }}</h1>
|
||||
@if ($description)
|
||||
<p class="mt-2 text-sm leading-6 text-slate-600">{{ $description }}</p>
|
||||
@endif
|
||||
@isset($actions)
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2">
|
||||
{{ $actions }}
|
||||
</div>
|
||||
@endisset
|
||||
</div>
|
||||
|
||||
@if (count($stats) > 0)
|
||||
<div class="scrollbar-hide flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 sm:flex sm:flex-wrap sm:justify-end sm:overflow-visible sm:pb-0 lg:gap-4" style="-ms-overflow-style:none;scrollbar-width:none;">
|
||||
@foreach ($stats as $stat)
|
||||
<div class="mobile-stats-card w-max min-w-[9rem] shrink-0 snap-start rounded-xl border border-slate-200 bg-white/90 px-4 py-3 text-center backdrop-blur-sm">
|
||||
<p class="whitespace-nowrap text-xl font-bold text-slate-900 sm:text-2xl">{{ $stat['value'] }}</p>
|
||||
<p class="mt-0.5 whitespace-nowrap text-[11px] font-medium text-slate-500">{{ $stat['label'] }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,8 @@
|
||||
@props([
|
||||
'name',
|
||||
'show' => false,
|
||||
'maxWidth' => '2xl'
|
||||
'maxWidth' => '2xl',
|
||||
'zIndex' => 'z-50',
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -52,7 +53,7 @@ $maxWidth = [
|
||||
x-transition:leave="transition-opacity ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-end sm:items-center sm:justify-center"
|
||||
class="fixed inset-0 {{ $zIndex }} flex items-end sm:items-center sm:justify-center"
|
||||
style="background: rgba(0,0,0,0.45); backdrop-filter: blur(3px);"
|
||||
>
|
||||
{{-- Backdrop click to close --}}
|
||||
|
||||
@@ -29,5 +29,8 @@
|
||||
@auth
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
@include('partials.sso-keepalive')
|
||||
@include('partials.confirm-prompt')
|
||||
@include('components.domain-purchase-modal')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,9 +36,9 @@
|
||||
@endphp
|
||||
|
||||
@php
|
||||
$ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all();
|
||||
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
|
||||
$oldDomain = trim((string) old('domain', ''));
|
||||
$initialDomainSource = $ownedDomains->isEmpty()
|
||||
$initialDomainSource = empty($ownedDomainHosts)
|
||||
? 'external'
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
@@ -54,10 +54,6 @@
|
||||
showSheet: false,
|
||||
checkoutUrl: '',
|
||||
renewLoading: false,
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwned),
|
||||
externalDomain: @js($externalFallback),
|
||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}',
|
||||
async submitRenewal() {
|
||||
this.renewLoading = true;
|
||||
try {
|
||||
@@ -69,13 +65,8 @@
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
|
||||
this.renewModal = false;
|
||||
if (window.innerWidth < 768) {
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true;
|
||||
this.renewLoading = false;
|
||||
} else {
|
||||
window.location.href = data.checkout_url;
|
||||
}
|
||||
this.checkoutUrl = data.checkout_url;
|
||||
this.showSheet = true; // renewLoading cleared when payment UI opens (ladill-pay-opened)
|
||||
} catch(e) {
|
||||
alert('Network error. Please try again.');
|
||||
this.renewLoading = false;
|
||||
@@ -86,7 +77,7 @@
|
||||
<nav class="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<a href="{{ route('hosting.single-domain') }}" class="hover:text-slate-700 transition">Hosting</a>
|
||||
<svg class="h-3.5 w-3.5 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5m0 0v-15"/></svg>
|
||||
<span class="font-medium text-slate-800">{{ $account->primary_domain ?: $account->username }}</span>
|
||||
<span class="font-medium text-slate-800">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
|
||||
</nav>
|
||||
|
||||
{{-- Expired Account Banner --}}
|
||||
@@ -154,7 +145,7 @@
|
||||
<div>
|
||||
<h2 class="text-xl font-bold tracking-tight text-slate-900">Hosting Account</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2.5">
|
||||
<span class="text-sm text-slate-700">{{ $account->primary_domain ?: $account->username }}</span>
|
||||
<span class="text-sm text-slate-700">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
|
||||
<span class="rounded-full px-2.5 py-0.5 text-[11px] font-semibold {{ $statusColors[$account->status ?? 'pending'] }}">
|
||||
{{ $statusLabels[$account->status ?? 'Pending'] }}
|
||||
</span>
|
||||
@@ -439,9 +430,14 @@
|
||||
@endforeach
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-400">
|
||||
{{ $linkedSites->count() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
|
||||
@if ($maxDomains === -1)
|
||||
{{ $account->domainSitesCount() }} domain {{ $account->domainSitesCount() === 1 ? 'slot' : 'slots' }} in use (unlimited).
|
||||
@else
|
||||
{{ $account->domainSitesCount() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
|
||||
@endif
|
||||
· {{ $account->subdomainSitesCount() }}{{ $account->maxSubdomainsLimit() === -1 ? '' : ' of '.$account->maxSubdomainsLimit() }} subdomain {{ $account->subdomainSitesCount() === 1 ? 'slot' : 'slots' }} in use.
|
||||
</p>
|
||||
@elseif ($account->primary_domain)
|
||||
@elseif ($account->primary_domain && ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($account->primary_domain))
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-slate-800">{{ $account->primary_domain }}</span>
|
||||
<span class="rounded-full px-2 py-0.5 text-[11px] font-semibold bg-emerald-100 text-emerald-700">
|
||||
@@ -472,9 +468,16 @@
|
||||
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative min-h-full sm:min-h-0 w-full sm:max-w-lg sm:rounded-2xl sm:border sm:border-slate-200 bg-white shadow-xl" @click.stop>
|
||||
|
||||
<form method="POST" action="{{ $addDomainAction }}">
|
||||
<form method="POST" action="{{ $addDomainAction }}"
|
||||
x-data="ladillDomainSourceForm({
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwned),
|
||||
externalDomain: @js($externalFallback),
|
||||
ownedDomains: @js($ownedDomainHosts),
|
||||
ownedUrl: @js(route('hosting.domains.owned', ['exclude' => $linkedSites->pluck('domain')->filter()->all()])),
|
||||
onboardingMode: @js(old('onboarding_mode', 'ns_auto')),
|
||||
})">
|
||||
@csrf
|
||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
||||
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<h3 class="text-base font-semibold text-slate-900">Connect a Domain</h3>
|
||||
@@ -483,83 +486,15 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-5">
|
||||
{{-- Domain Source Toggle --}}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-slate-700">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ladill (Owned) Domains --}}
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
@if($ownedDomains->isNotEmpty())
|
||||
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
|
||||
class="block w-full rounded-xl border-slate-200 bg-slate-50 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
|
||||
<option value="">Choose a domain...</option>
|
||||
@foreach($ownedDomains as $domain)
|
||||
<option value="{{ $domain->host }}">{{ $domain->host }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-slate-400">
|
||||
Don't have a domain?
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
|
||||
</p>
|
||||
@else
|
||||
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
|
||||
No domains found in your account.
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- External Domain --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input name="domain" type="text" x-model="externalDomain" :disabled="domainSource !== 'external'" placeholder="example.com"
|
||||
class="w-full rounded-xl border-slate-200 bg-slate-50 px-4 py-2.5 text-sm focus:border-indigo-500 focus:bg-white focus:ring-indigo-500">
|
||||
<p class="mt-1.5 text-xs text-slate-400">Enter your domain without www or http (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
@if ($formErrors->has('domain'))
|
||||
<p class="mt-2 text-xs text-rose-600">{{ $formErrors->first('domain') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Connection Method (External only) --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<label class="mb-2 block text-sm font-medium text-slate-700">Connection method</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
|
||||
:class="onboardingMode === 'ns_auto' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="ns_auto" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">Point Nameservers</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-start gap-3 rounded-xl border p-3.5 transition"
|
||||
:class="onboardingMode === 'manual_dns' ? 'border-indigo-300 bg-indigo-50/50' : 'border-slate-200 hover:border-slate-300'">
|
||||
<input type="radio" x-model="onboardingMode" name="onboarding_mode" value="manual_dns" class="mt-0.5 text-indigo-600 focus:ring-indigo-500">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">Add DNS Records</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<x-domain-source-fields
|
||||
:owned-domains="$ownedDomainHosts"
|
||||
owned-url="{{ route('hosting.domains.owned') }}"
|
||||
show-connection-method
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-1">
|
||||
<button @click="domainModal = false" type="button" class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50 transition">Cancel</button>
|
||||
<button type="submit"
|
||||
class="btn-primary">
|
||||
Add Domain
|
||||
</button>
|
||||
<button type="submit" class="btn-primary">Add Domain</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -584,7 +519,7 @@
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">Renew Hosting Plan</h3>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{{ $account->primary_domain ?: $account->username }}</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</p>
|
||||
</div>
|
||||
<button @click="renewModal = false" type="button" class="rounded-lg p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<x-app-layout title="Developers — Ladill Hosting">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Developers</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">API tokens to manage your hosting programmatically.</p>
|
||||
|
||||
@if($newToken)
|
||||
<div class="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 p-5">
|
||||
<p class="text-sm font-semibold text-emerald-900">Your new token — copy it now</p>
|
||||
<p class="mt-1 text-xs text-emerald-700">This is the only time it will be shown.</p>
|
||||
<div class="mt-3 flex items-center gap-2" x-data="{ copied: false }">
|
||||
<code class="flex-1 truncate rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800 ring-1 ring-emerald-200">{{ $newToken }}</code>
|
||||
<button @click="navigator.clipboard.writeText('{{ $newToken }}'); copied = true; setTimeout(() => copied = false, 1500)"
|
||||
class="rounded-lg bg-emerald-600 px-3 py-2 text-xs font-semibold text-white hover:bg-emerald-700">
|
||||
<span x-show="!copied">Copy</span><span x-show="copied" x-cloak>Copied ✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Create a token</h2>
|
||||
<form method="POST" action="{{ route('account.developers.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Token name</label>
|
||||
<input type="text" name="name" required placeholder="e.g. CI server"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('name')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button class="btn-primary">Generate token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Your tokens</h2></div>
|
||||
@forelse($tokens as $token)
|
||||
<div class="flex items-center justify-between px-5 py-3.5 {{ ! $loop->last ? 'border-b border-slate-50' : '' }}">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $token->name }}</p>
|
||||
<p class="text-xs text-slate-400">
|
||||
Created {{ $token->created_at->diffForHumans() }} ·
|
||||
{{ $token->last_used_at ? 'last used '.$token->last_used_at->diffForHumans() : 'never used' }}
|
||||
</p>
|
||||
</div>
|
||||
<x-confirm-dialog
|
||||
:name="'revoke-token-'.$token->id"
|
||||
title="Revoke API token?"
|
||||
:message="'Revoke '.$token->name.'? Apps using this token will stop working.'"
|
||||
:action="route('account.developers.destroy', $token->id)"
|
||||
method="DELETE"
|
||||
confirm-label="Revoke"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Revoke</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center text-sm text-slate-400">No tokens yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-900 p-5 text-slate-200">
|
||||
<h2 class="text-sm font-semibold text-white">Quick start</h2>
|
||||
<p class="mt-1 text-xs text-slate-400">Authenticate with a Bearer token. Base URL:</p>
|
||||
<code class="mt-2 block rounded-lg bg-black/40 px-3 py-2 font-mono text-[11px] text-emerald-300">{{ $apiBase }}</code>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-black/40 px-3 py-3 font-mono text-[11px] leading-relaxed text-slate-300"><code>curl {{ $apiBase }}/me \
|
||||
-H "Authorization: Bearer <your-token>" \
|
||||
-H "Accept: application/json"</code></pre>
|
||||
<p class="mt-3 text-[11px] text-slate-400">Endpoints: <span class="font-mono text-slate-300">GET /me</span>. More coming soon.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -1,88 +0,0 @@
|
||||
<x-app-layout title="Team — Ladill Hosting">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Team</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Invite people to help manage this account’s hosting.</p>
|
||||
|
||||
@if($canManage)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Invite a teammate</h2>
|
||||
<form method="POST" action="{{ route('account.team.store') }}" class="mt-4 flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
@csrf
|
||||
<div class="flex-1">
|
||||
<label class="block text-[11px] font-medium text-slate-500">Email</label>
|
||||
<input type="email" name="email" required placeholder="teammate@example.com"
|
||||
class="mt-1 w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
@error('email')<p class="mt-1 text-xs text-rose-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[11px] font-medium text-slate-500">Role</label>
|
||||
<select name="role" class="mt-1 rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm focus:border-indigo-300 focus:bg-white focus:outline-none">
|
||||
<option value="member">Member</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary">Send invite</button>
|
||||
</form>
|
||||
<p class="mt-2 text-[11px] text-slate-400">Admins can manage hosting and the team. Members can manage hosting. Invitees join by signing in with that email.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white">
|
||||
<div class="border-b border-slate-100 px-5 py-3.5"><h2 class="text-sm font-semibold text-slate-900">Members</h2></div>
|
||||
<ul class="divide-y divide-slate-50">
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-900 text-xs font-semibold text-white">{{ strtoupper(substr($account->name ?? $account->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $account->name ?? $account->email }} <span class="text-xs font-normal text-slate-400">(you)</span></p>
|
||||
<p class="text-xs text-slate-400">{{ $account->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="rounded-full bg-indigo-50 px-2.5 py-1 text-[11px] font-medium text-indigo-700">Owner</span>
|
||||
</li>
|
||||
|
||||
@forelse($members as $member)
|
||||
<li class="flex items-center justify-between px-5 py-3.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-xs font-semibold text-slate-600">{{ strtoupper(substr($member->email, 0, 1)) }}</span>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-900">{{ $member->member->name ?? $member->email }}</p>
|
||||
<p class="text-xs text-slate-400">{{ $member->email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@if($member->status === 'invited')
|
||||
<span class="rounded-full bg-amber-50 px-2.5 py-1 text-[11px] font-medium text-amber-700">Invited</span>
|
||||
@endif
|
||||
@if($canManage)
|
||||
<form method="POST" action="{{ route('account.team.role', $member) }}">
|
||||
@csrf @method('PATCH')
|
||||
<select name="role" onchange="this.form.submit()" class="rounded-lg border border-slate-200 bg-white px-2 py-1 text-xs focus:outline-none">
|
||||
<option value="member" @selected($member->role === 'member')>Member</option>
|
||||
<option value="admin" @selected($member->role === 'admin')>Admin</option>
|
||||
</select>
|
||||
</form>
|
||||
<x-confirm-dialog
|
||||
:name="'remove-member-'.$member->id"
|
||||
title="Remove team member?"
|
||||
:message="'Remove '.$member->email.' from this team?'"
|
||||
:action="route('account.team.destroy', $member)"
|
||||
method="DELETE"
|
||||
confirm-label="Remove"
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="text-xs font-medium text-rose-600 hover:underline">Remove</button>
|
||||
</x-slot:trigger>
|
||||
</x-confirm-dialog>
|
||||
@else
|
||||
<span class="rounded-full bg-slate-100 px-2.5 py-1 text-[11px] font-medium capitalize text-slate-600">{{ $member->role }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</li>
|
||||
@empty
|
||||
<li class="px-5 py-8 text-center text-sm text-slate-400">No teammates yet.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -2,14 +2,16 @@
|
||||
@section('title', 'Overview — Ladill Hosting')
|
||||
@section('content')
|
||||
<div class="mx-auto max-w-5xl">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900">Overview</h1>
|
||||
<p class="mt-0.5 text-sm text-slate-500">Your shared hosting at a glance.</p>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="truncate text-lg font-semibold tracking-tight text-slate-900 lg:text-xl">Overview</h1>
|
||||
<p class="mt-0.5 hidden text-sm text-slate-500 sm:block">Your shared hosting at a glance.</p>
|
||||
</div>
|
||||
<a href="{{ route('hosting.single-domain') }}" class="inline-flex items-center justify-center btn-primary">
|
||||
New hosting
|
||||
</a>
|
||||
@include('partials.mobile-header-btn', [
|
||||
'href' => route('hosting.single-domain'),
|
||||
'label' => 'New hosting',
|
||||
'variant' => 'primary',
|
||||
])
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
@@ -16,133 +16,137 @@
|
||||
@endif
|
||||
|
||||
@php
|
||||
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
|
||||
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
|
||||
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
|
||||
$oldDomain = trim((string) old('domain', ''));
|
||||
$initialDomainSource = $ownedDomains->isEmpty()
|
||||
$initialDomainSource = empty($ownedDomainHosts)
|
||||
? 'external'
|
||||
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
|
||||
$selectedOwnedDomain = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$externalFallbackDomain = ! in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
|
||||
$parentOptions = collect($parentSites ?? [])->values();
|
||||
$defaultParentId = (int) old('parent_site_id', $parentOptions->first()?->id ?? 0);
|
||||
$defaultParentHost = optional($parentOptions->firstWhere('id', $defaultParentId))->domain
|
||||
?? optional($parentOptions->first())->domain
|
||||
?? '';
|
||||
@endphp
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-4 sm:p-5">
|
||||
<div class="flex flex-wrap gap-4 text-sm text-slate-600">
|
||||
<p>
|
||||
@if ($maxDomains === -1)
|
||||
<span class="font-semibold text-slate-900">{{ $domainSitesCount }}</span> domain {{ $domainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited)
|
||||
@else
|
||||
<span class="font-semibold text-slate-900">{{ $domainSitesCount }} of {{ $maxDomains }}</span> domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use
|
||||
@endif
|
||||
</p>
|
||||
<p class="text-slate-300">|</p>
|
||||
<p>
|
||||
@if ($maxSubdomains === -1)
|
||||
<span class="font-semibold text-slate-900">{{ $subdomainSitesCount }}</span> subdomain {{ $subdomainSitesCount === 1 ? 'slot' : 'slots' }} in use (unlimited)
|
||||
@else
|
||||
<span class="font-semibold text-slate-900">{{ $subdomainSitesCount }} of {{ $maxSubdomains }}</span> subdomain {{ $maxSubdomains === 1 ? 'slot' : 'slots' }} in use
|
||||
@endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-4">Add Domain</h3>
|
||||
@if (! $canAddDomain)
|
||||
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">Domain slot limit reached. Upgrade your plan or remove an unused domain.</p>
|
||||
@endif
|
||||
<form action="{{ route('hosting.panel.domains.add', $account) }}" method="POST" class="space-y-5"
|
||||
x-data="{
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwnedDomain),
|
||||
externalDomain: @js($externalFallbackDomain),
|
||||
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
|
||||
}">
|
||||
x-data="ladillDomainSourceForm({
|
||||
domainSource: @js($initialDomainSource),
|
||||
selectedOwnedDomain: @js($selectedOwnedDomain),
|
||||
externalDomain: @js($externalFallbackDomain),
|
||||
ownedDomains: @js($ownedDomainHosts),
|
||||
ownedUrl: @js(route('hosting.domains.owned', ['exclude' => $account->sites->pluck('domain')->filter()->all()])),
|
||||
onboardingMode: @js(old('onboarding_mode', 'ns_auto')),
|
||||
})">
|
||||
@csrf
|
||||
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
|
||||
<x-domain-source-fields
|
||||
:owned-domains="$ownedDomainHosts"
|
||||
owned-url="{{ route('hosting.domains.owned') }}"
|
||||
show-connection-method
|
||||
show-document-root
|
||||
/>
|
||||
|
||||
{{-- Domain Source Toggle --}}
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-slate-700">Domain</label>
|
||||
<div class="inline-flex items-center rounded-md bg-slate-100 p-0.5 text-[11px] font-medium">
|
||||
<button type="button" @click="domainSource = 'ladill'"
|
||||
:class="domainSource === 'ladill' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">Ladill</button>
|
||||
<button type="button" @click="domainSource = 'external'"
|
||||
:class="domainSource === 'external' ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-500'"
|
||||
class="rounded px-2 py-1 transition">External</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Ladill (Owned) Domains --}}
|
||||
<div x-show="domainSource === 'ladill'" x-cloak>
|
||||
@if($ownedDomains->isNotEmpty())
|
||||
<select name="domain" x-model="selectedOwnedDomain" :disabled="domainSource !== 'ladill'"
|
||||
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<option value="">Choose a domain...</option>
|
||||
@foreach($ownedDomains as $domain)
|
||||
<option value="{{ $domain }}">{{ $domain }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1.5 text-xs text-slate-500">
|
||||
Don't have a domain?
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 hover:text-indigo-800">Purchase one first</a>
|
||||
</p>
|
||||
@else
|
||||
<div class="rounded-lg bg-slate-50 px-4 py-3 text-center text-sm text-slate-600">
|
||||
No domains found in your account.
|
||||
<a href="{{ ladill_domains_url('/find') }}" class="font-medium text-indigo-600 underline underline-offset-2 hover:text-indigo-800">Register a domain</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- External Domain --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<input type="text" name="domain" x-model="externalDomain" :disabled="domainSource !== 'external'" required placeholder="example.com"
|
||||
class="block w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500 @error('domain') border-red-300 @enderror">
|
||||
<p class="mt-1.5 text-xs text-slate-500">Enter your domain without www or http (e.g., example.com)</p>
|
||||
</div>
|
||||
|
||||
@error('domain')
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Onboarding Mode (External only) --}}
|
||||
<div x-show="domainSource === 'external'" x-cloak>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-3">Connection Method</label>
|
||||
<div class="space-y-3">
|
||||
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
|
||||
<input type="radio" name="onboarding_mode" value="ns_auto" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-emerald-100 text-emerald-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 0 1-3-3m3 3a3 3 0 1 0 0 6h13.5a3 3 0 1 0 0-6m-16.5-3a3 3 0 0 1 3-3h13.5a3 3 0 0 1 3 3m-19.5 0a4.5 4.5 0 0 1 .9-2.7L5.737 5.1a3.375 3.375 0 0 1 2.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 0 1 .9 2.7m0 0a3 3 0 0 1-3 3m0 3h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Zm-3 6h.008v.008h-.008v-.008Zm0-6h.008v.008h-.008v-.008Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Point Nameservers</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Point your domain's nameservers to Ladill. We'll manage DNS and SSL automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
|
||||
</label>
|
||||
|
||||
<label class="relative flex cursor-pointer rounded-lg border border-slate-200 bg-white p-4 hover:bg-slate-50 transition">
|
||||
<input type="radio" name="onboarding_mode" value="manual_dns" x-model="onboardingMode" class="sr-only peer">
|
||||
<div class="flex flex-1 items-start gap-3">
|
||||
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-blue-100 text-blue-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-semibold text-slate-900">Add DNS Records</p>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Keep your current DNS provider and manually add A records pointing to our server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute right-4 top-4 hidden h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white peer-checked:flex">
|
||||
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
|
||||
</div>
|
||||
<div class="absolute inset-0 rounded-lg border-2 border-transparent peer-checked:border-indigo-600 pointer-events-none"></div>
|
||||
</label>
|
||||
</div>
|
||||
@error('onboarding_mode')
|
||||
<p class="mt-1.5 text-sm text-red-600">{{ $message }}</p>
|
||||
@enderror
|
||||
</div>
|
||||
|
||||
{{-- Document Root (optional, both flows) --}}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Document Root <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="text" name="document_root" placeholder="public_html/example.com" value="{{ old('document_root') }}"
|
||||
class="w-full rounded-lg border-slate-200 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Leave empty to use default: public_html/domain.com</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">
|
||||
<button type="submit" class="btn-primary" @disabled(! $canAddDomain)>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"/></svg>
|
||||
Add Domain
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-1">Create subdomain</h3>
|
||||
<p class="text-sm text-slate-500 mb-4">Create a hostname under one of your attached domains (up to 5× your domain slot limit).</p>
|
||||
|
||||
@if ($parentOptions->isEmpty())
|
||||
<p class="text-sm text-slate-500">Add a primary or addon domain before creating subdomains.</p>
|
||||
@else
|
||||
@if (! $canAddSubdomain)
|
||||
<p class="text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2 mb-4">Subdomain slot limit reached.</p>
|
||||
@endif
|
||||
<form action="{{ route('hosting.panel.domains.subdomains.add', $account) }}" method="POST" class="space-y-4"
|
||||
x-data="{
|
||||
parentId: @js((string) $defaultParentId),
|
||||
parents: @js($parentOptions->mapWithKeys(fn ($s) => [(string) $s->id => $s->domain])->all()),
|
||||
label: @js(old('subdomain', '')),
|
||||
get preview() {
|
||||
const parent = this.parents[this.parentId] || '';
|
||||
const label = (this.label || '').toLowerCase().trim();
|
||||
return label && parent ? (label + '.' + parent) : parent;
|
||||
}
|
||||
}">
|
||||
@csrf
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="parent_site_id" class="mb-1 block text-xs font-medium text-slate-600">Parent domain</label>
|
||||
<select id="parent_site_id" name="parent_site_id" x-model="parentId" required
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@foreach ($parentOptions as $parent)
|
||||
<option value="{{ $parent->id }}" @selected((int) $defaultParentId === (int) $parent->id)>
|
||||
{{ $parent->domain }} ({{ $parent->type }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('parent_site_id') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div>
|
||||
<label for="subdomain" class="mb-1 block text-xs font-medium text-slate-600">Subdomain label</label>
|
||||
<input id="subdomain" type="text" name="subdomain" x-model="label"
|
||||
value="{{ old('subdomain') }}"
|
||||
placeholder="blog"
|
||||
pattern="[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
|
||||
maxlength="63"
|
||||
required
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
@error('subdomain') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-slate-600">Hostname preview</label>
|
||||
<p class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-mono text-slate-800" x-text="preview || '—'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subdomain_document_root" class="mb-1 block text-xs font-medium text-slate-600">Document root (optional)</label>
|
||||
<input id="subdomain_document_root" type="text" name="document_root"
|
||||
value="{{ old('document_root') }}"
|
||||
placeholder="public_html/blog.example.com"
|
||||
class="w-full rounded-lg border-slate-300 text-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<p class="mt-1 text-xs text-slate-500">Relative to /home/{{ $account->username }}/. Leave blank to use public_html/<hostname>.</p>
|
||||
@error('document_root') <p class="mt-1 text-xs text-red-600">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" @disabled(! $canAddSubdomain)>
|
||||
Create subdomain
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-200">
|
||||
<h3 class="text-base font-semibold text-slate-900">Your Domains</h3>
|
||||
@@ -152,13 +156,14 @@
|
||||
<thead class="bg-slate-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Domain</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Type</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Document Root</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-200">
|
||||
@foreach($account->sites as $site)
|
||||
@foreach ($account->sites->sortBy(fn ($site) => [match ($site->type) { 'primary' => 0, 'addon' => 1, default => 2 }, $site->domain]) as $site)
|
||||
<tr class="hover:bg-slate-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -168,6 +173,18 @@
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
@php
|
||||
$typeStyles = [
|
||||
'primary' => 'bg-indigo-100 text-indigo-800',
|
||||
'addon' => 'bg-slate-100 text-slate-800',
|
||||
'subdomain' => 'bg-sky-100 text-sky-800',
|
||||
];
|
||||
@endphp
|
||||
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {{ $typeStyles[$site->type] ?? 'bg-slate-100 text-slate-800' }}">
|
||||
{{ ucfirst($site->type) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="text-sm font-mono text-slate-600">{{ $site->document_root }}</span>
|
||||
</td>
|
||||
@@ -188,7 +205,6 @@
|
||||
>
|
||||
<x-slot:trigger>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition">
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/></svg>
|
||||
Remove
|
||||
</button>
|
||||
</x-slot:trigger>
|
||||
@@ -204,26 +220,19 @@
|
||||
@else
|
||||
<div class="px-6 py-12 text-center">
|
||||
@include('components.icons.domain-globe', ['class' => 'mx-auto h-12 w-12 text-slate-300'])
|
||||
<p class="mt-4 text-sm text-slate-500">No addon domains yet. Add one above.</p>
|
||||
<p class="mt-4 text-sm text-slate-500">No domains yet. Add one above.</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@php
|
||||
$serverIp = $account->node?->ip_address ?? '161.97.138.149';
|
||||
@endphp
|
||||
@php $serverIp = $account->node?->ip_address ?? '161.97.138.149'; @endphp
|
||||
|
||||
{{-- DNS Quick Reference --}}
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h3 class="text-base font-semibold text-slate-900 mb-2">DNS Quick Reference</h3>
|
||||
<p class="text-sm text-slate-600 mb-4">Use one of these methods to connect an external domain:</p>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-emerald-200 bg-emerald-50/50 p-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h4 class="text-sm font-semibold text-slate-900">Nameservers</h4>
|
||||
<span class="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-[10px] font-medium text-emerald-800">Recommended</span>
|
||||
</div>
|
||||
<h4 class="text-sm font-semibold text-slate-900 mb-2">Nameservers</h4>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between rounded bg-white border border-emerald-200 px-3 py-1.5">
|
||||
<span class="text-sm font-mono text-slate-900">ns1.ladill.com</span>
|
||||
@@ -235,27 +244,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
||||
<h4 class="text-sm font-semibold text-slate-900 mb-2">A Records</h4>
|
||||
<div class="space-y-1 rounded bg-white border border-slate-200 p-3">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-medium text-slate-500">Type</span>
|
||||
<span class="font-medium text-slate-500">Name</span>
|
||||
<span class="font-medium text-slate-500">Value</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-mono text-slate-900">A</span>
|
||||
<span class="font-mono text-slate-900">@</span>
|
||||
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<span class="font-mono text-slate-900">A</span>
|
||||
<span class="font-mono text-slate-900">www</span>
|
||||
<span class="font-mono text-slate-900">{{ $serverIp }}</span>
|
||||
</div>
|
||||
<div class="space-y-1 rounded bg-white border border-slate-200 p-3 text-xs font-mono">
|
||||
<div>A @ {{ $serverIp }}</div>
|
||||
<div>A www {{ $serverIp }}</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 24–48 hours.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||