1 Commits
Author SHA1 Message Date
isaaccladandClaude Opus 4.8 a754e8068b Add public product landing page at /
Serve a marketing landing page at the site root via ProductLandingController
instead of redirecting straight to login, with config/product_landing.php and
product views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 07:05:51 +00:00
136 changed files with 1121 additions and 6342 deletions
+3 -17
View File
@@ -27,14 +27,8 @@ DB_DATABASE=ladill_hosting
DB_USERNAME=ladill_hosting
DB_PASSWORD=
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_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=.ladill.com
@@ -42,7 +36,7 @@ SESSION_DOMAIN=.ladill.com
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=redis
CACHE_STORE=database
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
@@ -53,14 +47,6 @@ 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=
+14 -16
View File
@@ -19,9 +19,8 @@ jobs:
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
# 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 }}
RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-hosting-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-hosting
steps:
- name: Checkout
@@ -39,17 +38,11 @@ jobs:
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Build frontend assets
- name: Setup Node.js
shell: bash {0}
run: |
set -Eeuo pipefail
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
if [ ! -x "$NODE_ROOT/bin/node" ]; then
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
@@ -59,12 +52,17 @@ 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
"$NPM_BIN" ci --no-audit --no-fund
"$NPM_BIN" run build
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
@@ -80,7 +78,7 @@ jobs:
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}.tgz
LADILL_RELEASE_ARCHIVE: /tmp/ladill-hosting-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
-7
View File
@@ -65,17 +65,10 @@ 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
@@ -1,58 +0,0 @@
<?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;
}
}
@@ -1,130 +0,0 @@
<?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;
}
}
@@ -1,69 +0,0 @@
<?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,10 +131,7 @@ class RunHostingTerminalWorkerCommand extends Command
$launchCommand = $this->buildUserShellBootstrap($account, $relativePath, $cols, $rows);
if ($this->shouldUseLocalExecution($account->node)) {
// 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));
return new LocalPtyShellSession($this->buildLocalLaunchCommand($account, $launchCommand));
}
$ssh = $provider->connect($account->node);
@@ -143,39 +140,11 @@ class RunHostingTerminalWorkerCommand extends Command
throw new \RuntimeException('Unable to establish an SSH connection for the browser terminal.');
}
// 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);
$remoteCommand = "runuser -u {$account->username} -- bash -lc ".escapeshellarg($launchCommand);
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}";
@@ -183,14 +152,6 @@ 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),
@@ -200,9 +161,7 @@ class RunHostingTerminalWorkerCommand extends Command
'export COLORTERM=truecolor',
'export COLUMNS='.(int) $cols,
'export LINES='.(int) $rows,
'export PS1='.escapeshellarg($account->username.'@ladill:\W\$ '),
'printf %s '.escapeshellarg($inputrc).' > /tmp/ladill-term-inputrc-$$',
'export INPUTRC=/tmp/ladill-term-inputrc-$$',
'export PS1='.escapeshellarg($account->username.'@ladill:\w\$ '),
'cd '.escapeshellarg($workingDirectory).' 2>/dev/null || cd '.escapeshellarg($homeDirectory),
'stty rows '.(int) $rows.' cols '.(int) $cols.' echo 2>/dev/null || true',
'exec /bin/bash --noprofile --norc -i',
@@ -1,123 +0,0 @@
<?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\HostingTeamMember;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
use Illuminate\Http\RedirectResponse;
@@ -88,7 +88,10 @@ 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()->away((string) config('ladill.marketing_url'));
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
@@ -118,7 +121,7 @@ class SsoLoginController extends Controller
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
HostingTeamMember::linkPendingInvitesFor($user);
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
@@ -138,18 +141,6 @@ 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,7 +55,9 @@ class AfiaController extends Controller
HostingProduct::TYPE_WORDPRESS,
];
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
$accounts = HostingAccount::query()
->where(function ($query) use ($user, $sharedAccountIds) {
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Hosting;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill Email API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
* now: account + mailbox listing). The plaintext token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('hosting.account.developers', [
'tokens' => $request->user()->tokens()->latest()->get(),
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
'newToken' => session('new_token'),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
]);
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
return redirect()->route('account.developers')
->with('new_token', $token->plainTextToken)
->with('success', 'Token created — copy it now, it wont 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,7 +7,6 @@ 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;
@@ -16,7 +15,6 @@ 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;
@@ -1076,21 +1074,10 @@ 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(),
]);
}
@@ -1104,15 +1091,12 @@ class HostingPanelController extends Controller
$this->authorizeForRequestUser($request, 'manageDomains', $account);
$account->load(['node', 'product', 'sites']);
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}).";
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
if ($account->sites->count() >= $maxDomains) {
if ($request->wantsJson()) {
return response()->json(['message' => $message], 422);
return response()->json(['message' => "You have reached the maximum number of domains ({$maxDomains})."], 422);
}
return back()->with('error', $message);
return back()->with('error', "You have reached the maximum number of domains ({$maxDomains}).");
}
$isOwnedDomain = $request->boolean('is_owned_domain');
@@ -1132,18 +1116,6 @@ 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'], '/')
@@ -1162,23 +1134,16 @@ 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' => $promoteToPrimary ? 'primary' : ($existingSite->type ?: 'addon'),
'type' => $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));
@@ -1263,15 +1228,12 @@ 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' => $promoteToPrimary ? 'primary' : 'addon',
'type' => 'addon',
'php_version' => $account->php_version ?? '8.2',
'ssl_enabled' => false,
'status' => 'active',
@@ -1280,10 +1242,6 @@ 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);
@@ -1313,172 +1271,15 @@ 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.');
@@ -1488,137 +1289,6 @@ 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')
@@ -1627,13 +1297,35 @@ class HostingPanelController extends Controller
->filter()
->all();
$user = \App\Models\User::query()->find($userId);
$owned = $user
? app(\App\Services\Domains\LadillDomainsClient::class)->ownedForUser((string) $user->public_id)
: [];
$domainRegistryHosts = Domain::where('user_id', $userId)->pluck('host');
return collect($owned)
$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))
->reject(fn ($d) => in_array($d, $linkedDomains, true))
->unique()
->sort()
->values();
}
@@ -1792,33 +1484,17 @@ 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();
// In-flight orders only — Active accounts belong under Hosting Accounts
// (legacy Sales Centre left Active pending-setup.local orders that duplicated accounts).
// New Hosting Orders
$orders = CustomerHostingOrder::query()
->forUser($user->id)
->inFlight()
->whereHas('product', fn ($q) => $q->ofType($type))
->with(['product', 'domain', 'hostingAccount.sites', 'hostingNode'])
->with(['product', 'domain', 'hostingAccount', 'hostingNode'])
->latest()
->get();
// Admin-assigned Hosting Accounts
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
$hostingAccounts = HostingAccount::query()
->where(function ($query) use ($user, $sharedAccountIds) {
@@ -109,19 +109,6 @@ 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,
@@ -132,7 +119,6 @@ class HostingProductController extends Controller
'marketingCategory' => $marketingCategory,
'nativeForm' => $nativeForm,
'userDomains' => $userDomains,
'heroStats' => $heroStats,
]);
}
@@ -141,12 +127,11 @@ class HostingProductController extends Controller
$user = $request->user();
$serverOrders = app(ServerOrderService::class);
// In-flight orders only — Active accounts belong under Hosting Accounts
// New Hosting Orders
$orders = CustomerHostingOrder::query()
->forUser($user->id)
->inFlight()
->whereHas('product', fn ($q) => $q->ofType($type))
->with(['product', 'vpsInstance', 'hostingAccount.sites'])
->with(['product', 'vpsInstance'])
->latest()
->get();
@@ -212,14 +197,7 @@ class HostingProductController extends Controller
$renewals ??= app(HostingRenewalCheckoutService::class);
$account->load(['product', 'user', 'node', 'sites']);
// 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();
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
$freeEmailAllowance = $account->freeEmailAllowance();
$mailboxes = $account->loadedMailboxes();
$mailboxCount = $mailboxes->count();
@@ -227,14 +205,10 @@ class HostingProductController extends Controller
return view('hosting.account', [
'account' => $account,
'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(),
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
'maxDomains' => $maxDomains,
'canLinkMoreDomains' => $account->canAddDomain(),
'ownedDomains' => collect($this->getUserDomains($request->user())),
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
'ownedDomains' => $this->getUserDomains($request->user()),
'emailUsage' => [
'mailbox_count' => $mailboxCount,
'free_allowance' => $freeEmailAllowance,
@@ -658,9 +632,17 @@ class HostingProductController extends Controller
return $product->catalogSummary();
}
private function getUserDomains($user): array
private function getUserDomains($user): \Illuminate\Support\Collection
{
return app(\App\Services\Domains\LadillDomainsClient::class)
->ownedForUser((string) $user->public_id);
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']);
}
}
@@ -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,7 +100,9 @@ class OverviewController extends Controller
{
$sharedTypes = $this->sharedTypes();
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
$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,7 +49,9 @@ class SearchController extends Controller
HostingProduct::TYPE_WORDPRESS,
];
$sharedAccountIds = app(HostingAccessResolver::class)->sharedAccountIds($user);
$sharedAccountIds = HostingAccountMember::query()
->where('user_id', $user->id)
->pluck('hosting_account_id');
HostingAccount::query()
->where(function ($query) use ($user, $sharedAccountIds) {
@@ -1,26 +0,0 @@
<?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]);
}
}
@@ -0,0 +1,31 @@
<?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');
}
}
@@ -1,40 +0,0 @@
<?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),
]);
}
}
@@ -1,37 +0,0 @@
<?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);
}
}
@@ -1,73 +0,0 @@
<?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(),
]);
}
}
-46
View File
@@ -1,46 +0,0 @@
<?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;
}
}
+11 -18
View File
@@ -141,16 +141,12 @@ 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' => $primaryDomain,
'primary_domain' => $this->order->domain_name,
'type' => HostingAccount::TYPE_SHARED,
'status' => HostingAccount::STATUS_ACTIVE,
'home_directory' => $result['home_directory'] ?? "/home/{$username}",
@@ -168,7 +164,6 @@ 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,
@@ -183,18 +178,16 @@ class ProvisionHostingOrderJob implements ShouldQueue
], $resourceLimits),
]);
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,
]);
}
HostedSite::create([
'hosting_account_id' => $account->id,
'domain_id' => $this->order->domain_id,
'domain' => $this->order->domain_name,
'document_root' => ($result['document_root'] ?? "/home/{$username}/public_html"),
'type' => 'primary',
'status' => 'active',
'php_version' => '8.4',
'ssl_enabled' => false,
]);
$sharedProvider->applyAccountResourceProfile($account, false);
-20
View File
@@ -56,16 +56,6 @@ 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);
@@ -76,20 +66,10 @@ 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();
-47
View File
@@ -146,53 +146,6 @@ 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;
-13
View File
@@ -121,17 +121,4 @@ 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);
}
}
+2 -70
View File
@@ -12,7 +12,6 @@ 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
{
@@ -140,15 +139,7 @@ class HostingAccount extends Model
public function linkedDomainLabel(): ?string
{
if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) {
$domains = $this->sites
->pluck('domain')
->filter(fn ($domain) => filled($domain) && ! PlaceholderPrimaryDomainService::isPlaceholderDomain((string) $domain))
->unique()
->values();
if ($domains->isEmpty()) {
return null;
}
$domains = $this->sites->pluck('domain')->filter()->unique()->values();
if ($domains->count() === 1) {
return (string) $domains->first();
@@ -161,11 +152,7 @@ class HostingAccount extends Model
: $preview;
}
if (filled($this->primary_domain) && ! PlaceholderPrimaryDomainService::isPlaceholderDomain($this->primary_domain)) {
return (string) $this->primary_domain;
}
return null;
return filled($this->primary_domain) ? (string) $this->primary_domain : null;
}
public function databases(): HasMany
@@ -324,61 +311,6 @@ 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 ['database'];
return ['mail', 'database'];
}
public function toMail($notifiable): MailMessage
-34
View File
@@ -151,40 +151,6 @@ 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,7 +3,6 @@
namespace App\Services\Domain;
use App\Jobs\MailProvisioningJob;
use App\Jobs\ProvisionDomainSlaveZoneJob;
use App\Jobs\ProvisionDomainZoneJob;
use App\Jobs\SslProvisioningJob;
use App\Models\Domain;
@@ -13,7 +12,6 @@ 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
@@ -267,26 +265,7 @@ class DomainVerificationService
public function reprovision(Domain $domain): void
{
$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);
$this->dispatchProvisioningChain($domain->id);
}
private function dispatchProvisioningChain(int $domainId): void
@@ -1,84 +0,0 @@
<?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];
}
}
@@ -1,247 +0,0 @@
<?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,11 +10,6 @@ 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();
@@ -339,9 +334,7 @@ class BrowserTerminalSessionManager
private function waitForWorkerStartup(string $sessionId, array $metadata): array
{
// 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;
$deadline = microtime(true) + 2.5;
while (microtime(true) < $deadline) {
usleep(100000);
@@ -353,11 +346,28 @@ class BrowserTerminalSessionManager
}
}
// 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);
$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.';
}
}
@@ -1,88 +0,0 @@
<?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;
}
},
);
}
}
@@ -1,194 +0,0 @@
<?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,7 +909,16 @@ NGINX;
$domain = $site->domain;
$docRoot = $site->document_root ?: "/home/{$username}/public_html/{$domain}";
$this->ensureSiteDocumentRoot($ssh, $username, $docRoot);
// 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->hardenAccountFilesystem($account, [$docRoot]);
$this->applyManagedSiteConfig($ssh, $site, $docRoot, $username, [
@@ -922,24 +931,6 @@ 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;
@@ -1011,7 +1002,6 @@ 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", [
@@ -1032,28 +1022,22 @@ NGINX;
]);
}
// 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.
// Build domain list - include www if we manage DNS or if it resolves
$domainArgs = ' -d ' . escapeshellarg($domain);
$wwwDomain = "www.{$domain}";
$domainModel = $site->domain_id ? $site->domain()->first() : null;
$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) {
$dnsIsManaged = $domainModel && $domainModel->dns_mode === 'managed';
if ($dnsIsManaged) {
// We manage DNS, so www record exists - always include it
$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(
@@ -1693,7 +1677,7 @@ LIMITS;
'runtime_port' => $port,
'runtime_type' => $appType,
]),
])->save();
]);
try {
$this->applyManagedSiteConfig($ssh, $site, $site->document_root, $username, [
@@ -2467,7 +2451,14 @@ SERVICE;
NGINX;
if ($proxyPort !== null) {
$proxyHeaders = <<<NGINX
$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};
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
@@ -2478,27 +2469,6 @@ 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;
-17
View File
@@ -43,23 +43,6 @@ 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'))
@@ -1,31 +0,0 @@
<?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,
]);
}
}
-56
View File
@@ -1,56 +0,0 @@
<?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()]);
}
}
}
-163
View File
@@ -1,163 +0,0 @@
<?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);
}
}
-18
View File
@@ -23,24 +23,6 @@ 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
{
-5
View File
@@ -17,13 +17,8 @@ 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: [
-2
View File
@@ -11,6 +11,4 @@ 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',
];
-9
View File
@@ -1,9 +0,0 @@
<?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'),
];
-18
View File
@@ -1,24 +1,6 @@
<?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
-6
View File
@@ -1,6 +0,0 @@
<?php
return [
'product_slug' => 'hosting',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/hosting'),
];
+20 -26
View File
@@ -2,45 +2,39 @@
/*
|--------------------------------------------------------------------------
| Ladill App Launcher GENERATED, IDENTICAL across every app/service repo
| Ladill App Launcher SHARED, IDENTICAL across every app/service repo
|--------------------------------------------------------------------------
|
| 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
| 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)
|
| 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>.
| 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>.
*/
$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' => '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' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.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'],
],
];
-13
View File
@@ -1,13 +0,0 @@
<?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'),
];
-15
View File
@@ -1,15 +0,0 @@
<?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'),
]),
];
+43
View File
@@ -0,0 +1,43 @@
<?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',
];
+1 -1
View File
@@ -32,7 +32,7 @@ return [
|
*/
'lifetime' => (int) env('SESSION_LIFETIME', 1440),
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
-1
View File
@@ -25,7 +25,6 @@ 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(),
@@ -1,39 +0,0 @@
<?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');
}
};
@@ -1,56 +0,0 @@
<?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');
}
};
-8
View File
@@ -1,8 +0,0 @@
# 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
-32
View File
@@ -1,32 +0,0 @@
#!/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
-39
View File
@@ -1,39 +0,0 @@
#!/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."
+2 -21
View File
@@ -1,5 +1,5 @@
{
"name": "ladill-hosting",
"name": "ladill-email",
"lockfileVersion": 3,
"requires": true,
"packages": {
@@ -7,9 +7,7 @@
"dependencies": {
"@alpinejs/collapse": "^3.15.12",
"@tailwindcss/forms": "^0.5.11",
"alpinejs": "^3.15.12",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
"alpinejs": "^3.15.12"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
@@ -2491,23 +2489,6 @@
"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",
+1 -3
View File
@@ -17,8 +17,6 @@
"dependencies": {
"@alpinejs/collapse": "^3.15.12",
"@tailwindcss/forms": "^0.5.11",
"alpinejs": "^3.15.12",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0"
"alpinejs": "^3.15.12"
}
}
-12
View File
@@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-30
View File
@@ -1,30 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

-12
View File
@@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.2 KiB

-13
View File
@@ -1,13 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-13
View File
@@ -1,13 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

-12
View File
@@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-20
View File
@@ -1,20 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 2.3 KiB

-12
View File
@@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.6 KiB

-12
View File
@@ -1,12 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

-35
View File
@@ -1,35 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.3 KiB

+5 -10
View File
@@ -3,23 +3,18 @@
<defs>
<style>
.cls-1 {
fill: #007e7d;
fill: #f6398a;
}
.cls-1, .cls-2, .cls-3 {
.cls-1, .cls-2 {
stroke-width: 0px;
}
.cls-2 {
fill: #7b5c84;
}
.cls-3 {
fill: #f6398a;
fill: #007e7d;
}
</style>
</defs>
<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"/>
<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"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

+5 -5
View File
@@ -31,10 +31,10 @@
}
</style>
</defs>
<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"/>
<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"/>
<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.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"/>
<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"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 638 B

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 859 B

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-20
View File
@@ -1,20 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 557 B

-30
View File
@@ -1,30 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 839 B

-25
View File
@@ -1,25 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 734 B

-30
View File
@@ -1,30 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,31 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-73
View File
@@ -68,76 +68,3 @@
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;
}
-30
View File
@@ -1,24 +1,14 @@
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,
@@ -201,26 +191,6 @@ 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);
-608
View File
@@ -1,608 +0,0 @@
// 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(() => {});
},
};
};
-57
View File
@@ -1,57 +0,0 @@
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);
},
}));
}
-99
View File
@@ -1,99 +0,0 @@
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();
}
});
}
-97
View File
@@ -1,97 +0,0 @@
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,33 +1,17 @@
@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
aria-label="{{ $ariaLabel }}"
title="{{ $ariaLabel }}"
{{ $attributes->class(['btn-fab h-10 w-10 lg:hidden']) }}
{{ $attributes->class(['btn-primary']) }}
>
<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>
<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 }}>
@@ -1,29 +0,0 @@
@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>
@@ -1,11 +0,0 @@
<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>
@@ -1,141 +0,0 @@
@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,8 +68,6 @@
</div>
</div>
@include('partials.confirm-prompt')
@stack('scripts')
</body>
</html>
@@ -1,39 +0,0 @@
@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>
+2 -3
View File
@@ -1,8 +1,7 @@
@props([
'name',
'show' => false,
'maxWidth' => '2xl',
'zIndex' => 'z-50',
'maxWidth' => '2xl'
])
@php
@@ -53,7 +52,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 {{ $zIndex }} flex items-end sm:items-center sm:justify-center"
class="fixed inset-0 z-50 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,8 +29,5 @@
@auth
@include('partials.afia')
@endauth
@include('partials.sso-keepalive')
@include('partials.confirm-prompt')
@include('components.domain-purchase-modal')
</body>
</html>
+94 -29
View File
@@ -36,9 +36,9 @@
@endphp
@php
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
$ownedDomainHosts = $ownedDomains->pluck('host')->map(fn ($d) => (string) $d)->all();
$oldDomain = trim((string) old('domain', ''));
$initialDomainSource = empty($ownedDomainHosts)
$initialDomainSource = $ownedDomains->isEmpty()
? 'external'
: ((string) old('is_owned_domain', '1') === '0' ? 'external' : 'ladill');
$selectedOwned = in_array($oldDomain, $ownedDomainHosts, true) ? $oldDomain : '';
@@ -54,6 +54,10 @@
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 {
@@ -65,8 +69,13 @@
const data = await res.json();
if (!res.ok || data.error) { alert(data.error || 'Unable to start payment.'); this.renewLoading = false; return; }
this.renewModal = false;
this.checkoutUrl = data.checkout_url;
this.showSheet = true; // renewLoading cleared when payment UI opens (ladill-pay-opened)
if (window.innerWidth < 768) {
this.checkoutUrl = data.checkout_url;
this.showSheet = true;
this.renewLoading = false;
} else {
window.location.href = data.checkout_url;
}
} catch(e) {
alert('Network error. Please try again.');
this.renewLoading = false;
@@ -77,7 +86,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">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
<span class="font-medium text-slate-800">{{ $account->primary_domain ?: $account->username }}</span>
</nav>
{{-- Expired Account Banner --}}
@@ -145,7 +154,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">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</span>
<span class="text-sm text-slate-700">{{ $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>
@@ -430,14 +439,9 @@
@endforeach
</div>
<p class="mt-3 text-xs text-slate-400">
@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.
{{ $linkedSites->count() }} of {{ $maxDomains }} domain {{ $maxDomains === 1 ? 'slot' : 'slots' }} in use.
</p>
@elseif ($account->primary_domain && ! \App\Services\Hosting\PlaceholderPrimaryDomainService::isPlaceholderDomain($account->primary_domain))
@elseif ($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">
@@ -468,16 +472,9 @@
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 }}"
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')),
})">
<form method="POST" action="{{ $addDomainAction }}">
@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>
@@ -486,15 +483,83 @@
</button>
</div>
<div class="p-6 space-y-5">
<x-domain-source-fields
:owned-domains="$ownedDomainHosts"
owned-url="{{ route('hosting.domains.owned') }}"
show-connection-method
/>
{{-- 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>
<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>
@@ -519,7 +584,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">{{ $accountLabel ?? ($account->primary_domain ?: $account->username) }}</p>
<p class="mt-0.5 text-xs text-slate-500">{{ $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>
@@ -0,0 +1,73 @@
<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 &lt;your-token&gt;" \
-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>
@@ -0,0 +1,88 @@
<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 accounts 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>
+7 -9
View File
@@ -2,16 +2,14 @@
@section('title', 'Overview — Ladill Hosting')
@section('content')
<div class="mx-auto max-w-5xl">
<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 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>
@include('partials.mobile-header-btn', [
'href' => route('hosting.single-domain'),
'label' => 'New hosting',
'variant' => 'primary',
])
<a href="{{ route('hosting.single-domain') }}" class="inline-flex items-center justify-center btn-primary">
New hosting
</a>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
+139 -133
View File
@@ -16,137 +16,133 @@
@endif
@php
$errors = $errors ?? new \Illuminate\Support\ViewErrorBag;
$ownedDomainHosts = collect($ownedDomains)->map(fn ($d) => (string) $d)->all();
$ownedDomainHosts = $ownedDomains->map(fn ($d) => (string) $d)->all();
$oldDomain = trim((string) old('domain', ''));
$initialDomainSource = empty($ownedDomainHosts)
$initialDomainSource = $ownedDomains->isEmpty()
? '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="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')),
})">
x-data="{
domainSource: @js($initialDomainSource),
selectedOwnedDomain: @js($selectedOwnedDomain),
externalDomain: @js($externalFallbackDomain),
onboardingMode: '{{ old('onboarding_mode', 'ns_auto') }}'
}">
@csrf
<x-domain-source-fields
:owned-domains="$ownedDomainHosts"
owned-url="{{ route('hosting.domains.owned') }}"
show-connection-method
show-document-root
/>
<input type="hidden" name="is_owned_domain" :value="domainSource === 'ladill' ? '1' : '0'">
<button type="submit" class="btn-primary" @disabled(! $canAddDomain)>
{{-- 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">
<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/&lt;hostname&gt;.</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>
@@ -156,14 +152,13 @@
<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->sortBy(fn ($site) => [match ($site->type) { 'primary' => 0, 'addon' => 1, default => 2 }, $site->domain]) as $site)
@foreach($account->sites as $site)
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center gap-2">
@@ -173,18 +168,6 @@
</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>
@@ -205,6 +188,7 @@
>
<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>
@@ -220,19 +204,26 @@
@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 domains yet. Add one above.</p>
<p class="mt-4 text-sm text-slate-500">No addon 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">
<h4 class="text-sm font-semibold text-slate-900 mb-2">Nameservers</h4>
<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>
<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>
@@ -244,12 +235,27 @@
</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 text-xs font-mono">
<div>A @ {{ $serverIp }}</div>
<div>A www {{ $serverIp }}</div>
<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>
<p class="mt-2 text-xs text-slate-500">DNS changes can take up to 2448 hours.</p>
</div>
</div>
</div>

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