Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
public function connect(Request $request): RedirectResponse
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $request->query('redirect', route('hosting.dashboard')));
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = http_build_query([
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]);
|
||||
|
||||
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.$query);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$authLogin = 'https://'.config('app.auth_domain').'/login';
|
||||
|
||||
if ($request->filled('error') || ! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return redirect()->away($authLogin);
|
||||
}
|
||||
|
||||
$user = User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
|
||||
// Accept any pending team invites for this email on first sign-in.
|
||||
\App\Models\HostingTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->intended($request->session()->pull('sso.intended', route('hosting.dashboard')));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$home = 'https://'.config('app.platform_domain');
|
||||
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
|
||||
|
||||
return redirect()->away($endSession);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
if ($this->shouldLogoutForMailbox($request)) {
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
}
|
||||
|
||||
// SLO chain: if the central sequencer passed a (ladill.com) return URL,
|
||||
// continue the top-level redirect chain to the next app; else acknowledge.
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function shouldLogoutForMailbox(Request $request): bool
|
||||
{
|
||||
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
|
||||
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return strtolower((string) $user->email) !== $mailbox;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Account — Wallet, Billing, Settings. Money is read from the one platform
|
||||
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
|
||||
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WalletPaymentService $wallet,
|
||||
private BillingClient $billing,
|
||||
private MailboxClient $mailboxes,
|
||||
private IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** Wallet: balance + email spend summary + add-funds link. */
|
||||
public function wallet(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
return view('email.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
|
||||
public function billing(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
$paidMailboxes = [];
|
||||
$monthlyTotalMinor = 0;
|
||||
try {
|
||||
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
|
||||
if (! ($m['is_paid'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
|
||||
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
|
||||
$monthlyTotalMinor += $m['price_minor'];
|
||||
$paidMailboxes[] = $m;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('email.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'paidMailboxes' => $paidMailboxes,
|
||||
'monthlyTotalMinor' => $monthlyTotalMinor,
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
|
||||
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
|
||||
|
||||
return view('email.account.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
]);
|
||||
}
|
||||
|
||||
public function linkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'mailbox_address' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->identity->linkMailbox(
|
||||
(string) $account->public_id,
|
||||
$data['mailbox_address'],
|
||||
);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
|
||||
if ($connectUrl === '') {
|
||||
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
|
||||
}
|
||||
|
||||
return redirect()->away($connectUrl);
|
||||
}
|
||||
|
||||
public function unlinkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
try {
|
||||
$this->identity->unlinkMailbox((string) $account->public_id);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
|
||||
}
|
||||
|
||||
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
|
||||
private function mailboxLinkContext(?User $account): array
|
||||
{
|
||||
$linkStatus = ['show_reminder' => false];
|
||||
$mailboxOptions = [];
|
||||
|
||||
if (! $account?->public_id) {
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => false,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
|
||||
}
|
||||
|
||||
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|
||||
|| ($linkStatus['show_reminder'] ?? false)
|
||||
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
|
||||
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
|
||||
{
|
||||
if ($linkStatus['linked_mailbox'] ?? null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = strtolower(trim($account->email ?? ''));
|
||||
if ($email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($mailboxOptions as $mailbox) {
|
||||
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count($mailboxOptions) > 0;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
/** @return array<string, mixed> */
|
||||
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
|
||||
{
|
||||
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
|
||||
|
||||
return [
|
||||
'show_reminder' => $needsLink,
|
||||
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
|
||||
'linked_mailbox' => null,
|
||||
'account_email' => $account->email,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
EmailSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'default_quota_mb' => $data['default_quota_mb'] ?? null,
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:array<string,mixed>} [balanceMinor, ledger]
|
||||
*/
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->wallet->balanceMinor(ladill_account()),
|
||||
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [0, []];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Ladill Email — Afia chat endpoint. Grounds the assistant in the user's live
|
||||
* mailboxes/domains so answers are specific.
|
||||
*/
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'history' => ['nullable', 'array', 'max:20'],
|
||||
'history.*.role' => ['nullable', 'string'],
|
||||
'history.*.text' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (! $afia->enabled()) {
|
||||
return response()->json(['message' => 'Afia is not available right now.'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> Live email context for grounding. */
|
||||
private function context(): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
if (! $account) {
|
||||
return ['signed_in' => 'no'];
|
||||
}
|
||||
|
||||
$pid = (string) $account->public_id;
|
||||
$ctx = ['signed_in' => 'yes'];
|
||||
|
||||
try {
|
||||
$mailboxes = app(MailboxClient::class)->forUser($pid);
|
||||
$ctx['mailboxes_total'] = count($mailboxes);
|
||||
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$domains = app(EmailDomainClient::class)->forUser($pid);
|
||||
$ctx['domains_total'] = count($domains);
|
||||
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill Email API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
|
||||
* now: account + mailbox listing). The plaintext token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('email.account.developers', [
|
||||
'tokens' => $request->user()->tokens()->latest()->get(),
|
||||
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
|
||||
'newToken' => session('new_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
|
||||
|
||||
return redirect()->route('account.developers')
|
||||
->with('new_token', $token->plainTextToken)
|
||||
->with('success', 'Token created — copy it now, it won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** Email domain setup (add → publish DNS → verify), via the §4-D API. */
|
||||
class DomainsController extends Controller
|
||||
{
|
||||
public function __construct(private EmailDomainClient $domains) {}
|
||||
|
||||
private function pid(): string
|
||||
{
|
||||
return (string) ladill_account()->public_id;
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$domains = [];
|
||||
$error = null;
|
||||
try {
|
||||
$domains = $this->domains->forUser($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Could not load your domains.';
|
||||
}
|
||||
|
||||
return view('email.domains.index', compact('domains', 'error'));
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['domain' => ['required', 'string', 'max:253']]);
|
||||
|
||||
try {
|
||||
$domain = $this->domains->create($this->pid(), strtolower(trim($data['domain'])));
|
||||
|
||||
return redirect()->route('email.domains.show', $domain['id'])
|
||||
->with('success', 'Domain added — publish the DNS records below, then verify.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return back()->with('error', 'Could not add that domain. It may already exist.');
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$domain = $this->domains->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.domains.index')->with('error', 'Domain not found.');
|
||||
}
|
||||
|
||||
return view('email.domains.show', ['domain' => $domain]);
|
||||
}
|
||||
|
||||
public function verify(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$domain = $this->domains->verify($this->pid(), $id);
|
||||
$ok = $domain['active'] ?? false;
|
||||
|
||||
return redirect()->route('email.domains.show', $id)
|
||||
->with($ok ? 'success' : 'error', $ok ? 'Domain verified — you can now create mailboxes.' : 'Not verified yet. DNS can take time to propagate.');
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Verification failed. Try again shortly.');
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->domains->delete($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not remove that domain.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.domains.index')->with('success', 'Domain removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MailboxLinkBannerController extends Controller
|
||||
{
|
||||
public function dismiss(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->put('mailbox_link_banner_dismissed', true);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** My Mailboxes — list/create/manage, via the §4 Mailbox API. */
|
||||
class MailboxesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private MailboxClient $mailboxes,
|
||||
private EmailDomainClient $domains,
|
||||
private WalletPaymentService $wallet,
|
||||
) {}
|
||||
|
||||
private function pid(): string
|
||||
{
|
||||
return (string) ladill_account()->public_id;
|
||||
}
|
||||
|
||||
/** Storage tiers + the default (free) plan. Pricing is purely per-tier. */
|
||||
private function quota(): array
|
||||
{
|
||||
return [
|
||||
'tiers' => MailboxPricing::tiers(),
|
||||
'default_mb' => MailboxPricing::defaultQuotaMb(),
|
||||
'currency' => config('email.currency', 'GHS'),
|
||||
];
|
||||
}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$mailboxes = [];
|
||||
$error = null;
|
||||
try {
|
||||
$mailboxes = $this->mailboxes->forUser($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Could not load your mailboxes.';
|
||||
}
|
||||
|
||||
$q = trim((string) $request->query('q', ''));
|
||||
if ($q !== '') {
|
||||
$needle = mb_strtolower($q);
|
||||
$mailboxes = array_values(array_filter($mailboxes, fn ($m) => str_contains(
|
||||
mb_strtolower(($m['address'] ?? '').' '.($m['display_name'] ?? '')), $needle
|
||||
)));
|
||||
}
|
||||
|
||||
return view('email.mailboxes.index', ['mailboxes' => $mailboxes, 'error' => $error, 'q' => $q]);
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
$domains = [];
|
||||
try {
|
||||
$domains = $this->domains->verified($this->pid());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('email.mailboxes.create', [
|
||||
'domains' => $domains,
|
||||
'quota' => $this->quota(),
|
||||
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'email_domain_id' => ['required', 'integer'],
|
||||
'local_part' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9._%+-]+$/i'],
|
||||
'display_name' => ['required', 'string', 'max:160'],
|
||||
'password' => ['required', 'string', 'min:8', 'max:190', 'confirmed'],
|
||||
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
|
||||
if (! MailboxPricing::isValidQuota((int) $value)) {
|
||||
$fail('Choose a valid storage plan.');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
$user = ladill_account();
|
||||
$quotaMb = (int) $data['quota_mb'];
|
||||
$price = MailboxPricing::priceMinorFor($quotaMb); // 1 GB = 0 (free forever)
|
||||
$isPaid = $price > 0;
|
||||
$reference = 'mbx_'.Str::lower(Str::random(20));
|
||||
|
||||
// Paid tiers are wallet-funded (debit first); insufficient → top up.
|
||||
if ($isPaid) {
|
||||
if (! $this->wallet->canAfford($user, $price)) {
|
||||
return back()->withInput()
|
||||
->with('error', 'Your wallet balance is too low for this storage plan. Please top up and try again.')
|
||||
->with('topup', true);
|
||||
}
|
||||
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_create', 'Mailbox '.strtolower($data['local_part']))) {
|
||||
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$mailbox = $this->mailboxes->create(
|
||||
$this->pid(),
|
||||
(int) $data['email_domain_id'],
|
||||
strtolower(trim($data['local_part'])),
|
||||
$data['display_name'],
|
||||
$data['password'],
|
||||
$quotaMb,
|
||||
$isPaid,
|
||||
$isPaid ? $reference : null,
|
||||
);
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $mailbox['id'])
|
||||
->with('success', 'Mailbox '.$mailbox['address'].' is being set up.');
|
||||
} catch (\Throwable $e) {
|
||||
// Provisioning failed after a charge — refund so the customer isn't out of pocket.
|
||||
if ($isPaid) {
|
||||
$this->wallet->refund($user, $price, $reference, 'Mailbox creation failed');
|
||||
}
|
||||
$msg = $e instanceof \Illuminate\Http\Client\RequestException
|
||||
? (string) ($e->response?->json('message') ?? 'Could not create the mailbox.')
|
||||
: 'Could not create the mailbox.';
|
||||
report($e);
|
||||
|
||||
return back()->withInput()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function show(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
return view('email.mailboxes.show', ['mailbox' => $mailbox]);
|
||||
}
|
||||
|
||||
/** Upgrade form — pick a larger storage plan for an existing mailbox. */
|
||||
public function upgrade(int $id): View|RedirectResponse
|
||||
{
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
$currentMb = (int) ($mailbox['quota_mb'] ?? 0);
|
||||
// Only offer plans larger than the current one.
|
||||
$tiers = array_values(array_filter(MailboxPricing::tiers(), fn ($t) => $t['mb'] > $currentMb));
|
||||
|
||||
if (empty($tiers)) {
|
||||
return redirect()->route('email.mailboxes.show', $id)->with('error', 'This mailbox is already on the largest plan.');
|
||||
}
|
||||
|
||||
return view('email.mailboxes.upgrade', [
|
||||
'mailbox' => $mailbox,
|
||||
'currentMb' => $currentMb,
|
||||
'tiers' => $tiers,
|
||||
'defaultMb' => $tiers[0]['mb'],
|
||||
'currency' => config('email.currency', 'GHS'),
|
||||
'walletBalanceMinor' => $this->wallet->balanceMinor(ladill_account()),
|
||||
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Apply the upgrade — charge the new tier, then change the quota. */
|
||||
public function upgradeStore(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'quota_mb' => ['required', 'integer', function ($attr, $value, $fail) {
|
||||
if (! MailboxPricing::isValidQuota((int) $value)) {
|
||||
$fail('Choose a valid storage plan.');
|
||||
}
|
||||
}],
|
||||
]);
|
||||
|
||||
$user = ladill_account();
|
||||
$newMb = (int) $data['quota_mb'];
|
||||
|
||||
try {
|
||||
$mailbox = $this->mailboxes->show($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return redirect()->route('email.mailboxes.index')->with('error', 'Mailbox not found.');
|
||||
}
|
||||
|
||||
if ($newMb <= (int) ($mailbox['quota_mb'] ?? 0)) {
|
||||
return back()->withInput()->with('error', 'Choose a larger plan than your current one.');
|
||||
}
|
||||
|
||||
$price = MailboxPricing::priceMinorFor($newMb);
|
||||
$isPaid = $price > 0;
|
||||
$reference = 'mbxup_'.Str::lower(Str::random(20));
|
||||
|
||||
if ($isPaid) {
|
||||
if (! $this->wallet->canAfford($user, $price)) {
|
||||
return back()->withInput()
|
||||
->with('error', 'Your wallet balance is too low for this plan. Please top up and try again.')
|
||||
->with('topup', true);
|
||||
}
|
||||
if (! $this->wallet->charge($user, $price, $reference, 'mailbox_upgrade', 'Upgrade '.($mailbox['address'] ?? 'mailbox').' to '.MailboxPricing::label($newMb))) {
|
||||
return back()->withInput()->with('error', 'Could not charge your wallet. Please try again.')->with('topup', true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$this->mailboxes->updateQuota($this->pid(), $id, $newMb, $isPaid, $isPaid ? $reference : null);
|
||||
} catch (\Throwable $e) {
|
||||
if ($isPaid) {
|
||||
$this->wallet->refund($user, $price, $reference, 'Mailbox upgrade failed');
|
||||
}
|
||||
report($e);
|
||||
|
||||
return back()->withInput()->with('error', 'Could not upgrade the mailbox.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $id)
|
||||
->with('success', 'Upgraded to '.MailboxPricing::label($newMb).'.');
|
||||
}
|
||||
|
||||
public function resetPassword(Request $request, int $id): RedirectResponse
|
||||
{
|
||||
$data = $request->validate(['password' => ['required', 'string', 'min:8', 'max:190', 'confirmed']]);
|
||||
|
||||
try {
|
||||
$this->mailboxes->resetPassword($this->pid(), $id, $data['password']);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not update the password.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.show', $id)->with('success', 'Password updated.');
|
||||
}
|
||||
|
||||
public function destroy(int $id): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$this->mailboxes->delete($this->pid(), $id);
|
||||
} catch (\Throwable $e) {
|
||||
return back()->with('error', 'Could not delete the mailbox.');
|
||||
}
|
||||
|
||||
return redirect()->route('email.mailboxes.index')->with('success', 'Mailbox deleted.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private MailboxClient $mailboxes,
|
||||
private EmailDomainClient $domains,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$pid = (string) ladill_account()->public_id;
|
||||
$mailboxes = [];
|
||||
$domains = [];
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$mailboxes = $this->mailboxes->forUser($pid);
|
||||
$domains = $this->domains->forUser($pid);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$error = 'Some data could not be loaded right now.';
|
||||
}
|
||||
|
||||
return view('email.dashboard', [
|
||||
'mailboxCount' => count($mailboxes),
|
||||
'domainCount' => count($domains),
|
||||
'verifiedDomainCount' => collect($domains)->where('active', true)->count(),
|
||||
'recent' => collect($mailboxes)->take(5)->all(),
|
||||
'error' => $error,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Email;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Ladill Email — Team. Invite teammates to an account and manage their roles;
|
||||
* members can then manage that account's mailboxes & domains. Members accept by
|
||||
* signing into Email with the invited email (SSO). Owner and admins can manage.
|
||||
*/
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = EmailTeamMember::query()
|
||||
->where('account_id', $account->id)
|
||||
->with('member')
|
||||
->orderBy('status')->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('email.account.team', [
|
||||
'account' => $account,
|
||||
'members' => $members,
|
||||
'canManage' => $this->canManage($request),
|
||||
'isOwner' => $request->user()->id === $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:admin,member'],
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$email = strtolower($validated['email']);
|
||||
|
||||
if ($email === strtolower($account->email)) {
|
||||
return back()->withErrors(['email' => 'The owner is already on the account.']);
|
||||
}
|
||||
|
||||
$member = EmailTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
|
||||
$member->role = $validated['role'];
|
||||
if (! $member->exists) {
|
||||
$member->status = EmailTeamMember::STATUS_INVITED;
|
||||
$member->token = Str::random(40);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
|
||||
EmailTeamMember::linkPendingInvitesFor($existing);
|
||||
}
|
||||
|
||||
return back()->with('success', $email.' invited.');
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, EmailTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
|
||||
$member->update(['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', 'Role updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, EmailTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Member removed.');
|
||||
}
|
||||
|
||||
/** Switch the acting account (own, or one you're a member of). */
|
||||
public function switchAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate(['account' => ['required', 'integer']]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', (int) $validated['account']);
|
||||
|
||||
return redirect()->route('email.dashboard');
|
||||
}
|
||||
|
||||
private function canManage(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$account = ladill_account();
|
||||
|
||||
if ($user->id === $account->id) {
|
||||
return true; // owner
|
||||
}
|
||||
|
||||
return $user->memberships()
|
||||
->where('account_id', $account->id)
|
||||
->where('role', EmailTeamMember::ROLE_ADMIN)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EmailSetting;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Identity\IdentityClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use App\Services\Payment\WalletPaymentService;
|
||||
use App\Support\MailboxPricing;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Account — Wallet, Billing, Settings. Money is read from the one platform
|
||||
* UserWallet via the Billing API (tagged 'email'); funding is platform-level, so
|
||||
* "Add funds" deep-links to the account portal. Settings are email-local prefs.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private WalletPaymentService $wallet,
|
||||
private BillingClient $billing,
|
||||
private MailboxClient $mailboxes,
|
||||
private IdentityClient $identity,
|
||||
) {}
|
||||
|
||||
private function topupUrl(): string
|
||||
{
|
||||
return 'https://'.config('app.account_domain').'/wallet';
|
||||
}
|
||||
|
||||
/** Wallet: balance + email spend summary + add-funds link. */
|
||||
public function wallet(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
return view('hosting.account.wallet', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Billing: wallet snapshot + recurring paid-mailbox subscriptions. */
|
||||
public function billing(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
[$balanceMinor, $ledger] = $this->billingSnapshot($account?->public_id);
|
||||
|
||||
$paidMailboxes = [];
|
||||
$monthlyTotalMinor = 0;
|
||||
try {
|
||||
foreach ($this->mailboxes->forUser((string) $account?->public_id) as $m) {
|
||||
if (! ($m['is_paid'] ?? false)) {
|
||||
continue;
|
||||
}
|
||||
$m['price_minor'] = MailboxPricing::priceMinorFor((int) ($m['quota_mb'] ?? 0));
|
||||
$m['quota_label'] = MailboxPricing::label((int) ($m['quota_mb'] ?? 0));
|
||||
$monthlyTotalMinor += $m['price_minor'];
|
||||
$paidMailboxes[] = $m;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return view('hosting.account.billing', [
|
||||
'balanceMinor' => $balanceMinor,
|
||||
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
|
||||
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
|
||||
'paidMailboxes' => $paidMailboxes,
|
||||
'monthlyTotalMinor' => $monthlyTotalMinor,
|
||||
'topupUrl' => $this->topupUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function settings(): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
$settings = EmailSetting::firstOrNew(['user_id' => $account?->id]);
|
||||
['linkStatus' => $linkStatus, 'mailboxOptions' => $mailboxOptions, 'showMailboxLinkUi' => $showMailboxLinkUi] = $this->mailboxLinkContext($account);
|
||||
|
||||
return view('hosting.account.settings', [
|
||||
'account' => $account,
|
||||
'settings' => $settings,
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
]);
|
||||
}
|
||||
|
||||
public function linkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'mailbox_address' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$result = $this->identity->linkMailbox(
|
||||
(string) $account->public_id,
|
||||
$data['mailbox_address'],
|
||||
);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not link mailbox. Try again in a moment, or contact support if this keeps happening.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
$connectUrl = (string) ($result['webmail_connect_url'] ?? '');
|
||||
if ($connectUrl === '') {
|
||||
$connectUrl = rtrim((string) config('services.ladill_webmail.url', 'https://mail.ladill.com'), '/').'/sso/connect';
|
||||
}
|
||||
|
||||
return redirect()->away($connectUrl);
|
||||
}
|
||||
|
||||
public function unlinkMailbox(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
try {
|
||||
$this->identity->unlinkMailbox((string) $account->public_id);
|
||||
} catch (\Illuminate\Http\Client\RequestException $e) {
|
||||
$message = (string) $e->response?->json('message', '');
|
||||
if ($message !== '') {
|
||||
return redirect()->route('account.settings')->with('error', $message);
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return redirect()->route('account.settings')->with('error', 'Could not unlink mailbox. Try again in a moment.');
|
||||
}
|
||||
|
||||
$request->session()->forget('mailbox_link_banner_dismissed');
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Mailbox unlinked from your Ladill account.');
|
||||
}
|
||||
|
||||
/** @return array{linkStatus: array<string, mixed>, mailboxOptions: array<int, array<string, mixed>>, showMailboxLinkUi: bool} */
|
||||
private function mailboxLinkContext(?User $account): array
|
||||
{
|
||||
$linkStatus = ['show_reminder' => false];
|
||||
$mailboxOptions = [];
|
||||
|
||||
if (! $account?->public_id) {
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => false,
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$mailboxOptions = $this->mailboxes->forUser((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$linkStatus = $this->identity->mailboxLinkStatus((string) $account->public_id);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$linkStatus = $this->localMailboxLinkStatus($account, $mailboxOptions);
|
||||
}
|
||||
|
||||
$showMailboxLinkUi = (bool) ($linkStatus['linked_mailbox'] ?? null)
|
||||
|| ($linkStatus['show_reminder'] ?? false)
|
||||
|| $this->localNeedsMailboxLink($account, $mailboxOptions, $linkStatus);
|
||||
|
||||
return [
|
||||
'linkStatus' => $linkStatus,
|
||||
'mailboxOptions' => $mailboxOptions,
|
||||
'showMailboxLinkUi' => $showMailboxLinkUi,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
private function localNeedsMailboxLink(User $account, array $mailboxOptions, array $linkStatus): bool
|
||||
{
|
||||
if ($linkStatus['linked_mailbox'] ?? null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$email = strtolower(trim($account->email ?? ''));
|
||||
if ($email === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($mailboxOptions as $mailbox) {
|
||||
if (strtolower((string) ($mailbox['address'] ?? '')) === $email) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return count($mailboxOptions) > 0;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $mailboxOptions */
|
||||
/** @return array<string, mixed> */
|
||||
private function localMailboxLinkStatus(User $account, array $mailboxOptions): array
|
||||
{
|
||||
$needsLink = $this->localNeedsMailboxLink($account, $mailboxOptions, []);
|
||||
|
||||
return [
|
||||
'show_reminder' => $needsLink,
|
||||
'stage' => count($mailboxOptions) > 0 ? 'needs_link' : 'needs_mailbox',
|
||||
'linked_mailbox' => null,
|
||||
'account_email' => $account->email,
|
||||
];
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$data = $request->validate([
|
||||
'default_quota_mb' => ['nullable', 'integer', 'min:256', 'max:153600'],
|
||||
'notify_email' => ['nullable', 'email', 'max:255'],
|
||||
'product_updates' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
EmailSetting::updateOrCreate(
|
||||
['user_id' => $account->id],
|
||||
[
|
||||
'default_quota_mb' => $data['default_quota_mb'] ?? null,
|
||||
'notify_email' => $data['notify_email'] ?? null,
|
||||
'product_updates' => $request->boolean('product_updates'),
|
||||
],
|
||||
);
|
||||
|
||||
return redirect()->route('account.settings')->with('success', 'Settings saved.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:int,1:array<string,mixed>} [balanceMinor, ledger]
|
||||
*/
|
||||
private function billingSnapshot(?string $publicId): array
|
||||
{
|
||||
if (! $publicId) {
|
||||
return [0, []];
|
||||
}
|
||||
|
||||
try {
|
||||
return [
|
||||
$this->wallet->balanceMinor(ladill_account()),
|
||||
$this->billing->serviceLedger($publicId, (string) config('billing.service', 'email')),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return [0, []];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\EmailDomain\EmailDomainClient;
|
||||
use App\Services\Mailbox\MailboxClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Ladill Email — Afia chat endpoint. Grounds the assistant in the user's live
|
||||
* mailboxes/domains so answers are specific.
|
||||
*/
|
||||
class AfiaController extends Controller
|
||||
{
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'message' => ['required', 'string', 'max:2000'],
|
||||
'history' => ['nullable', 'array', 'max:20'],
|
||||
'history.*.role' => ['nullable', 'string'],
|
||||
'history.*.text' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
if (! $afia->enabled()) {
|
||||
return response()->json(['message' => 'Afia is not available right now.'], 503);
|
||||
}
|
||||
|
||||
try {
|
||||
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
|
||||
}
|
||||
|
||||
return response()->json(['reply' => $reply]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> Live email context for grounding. */
|
||||
private function context(): array
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
if (! $account) {
|
||||
return ['signed_in' => 'no'];
|
||||
}
|
||||
|
||||
$pid = (string) $account->public_id;
|
||||
$ctx = ['signed_in' => 'yes'];
|
||||
|
||||
try {
|
||||
$mailboxes = app(MailboxClient::class)->forUser($pid);
|
||||
$ctx['mailboxes_total'] = count($mailboxes);
|
||||
$ctx['mailboxes_paid'] = count(array_filter($mailboxes, fn ($m) => (bool) ($m['is_paid'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$domains = app(EmailDomainClient::class)->forUser($pid);
|
||||
$ctx['domains_total'] = count($domains);
|
||||
$ctx['domains_verified'] = count(array_filter($domains, fn ($d) => (bool) ($d['active'] ?? $d['verified'] ?? false)));
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor($pid) / 100, 2);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return $ctx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Developers — personal API tokens for the Ladill Email API (Sanctum). Tokens
|
||||
* belong to the signed-in user and authorize calls to /api/v1/* (read-only for
|
||||
* now: account + mailbox listing). The plaintext token is shown once on create.
|
||||
*/
|
||||
class DeveloperController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
return view('hosting.account.developers', [
|
||||
'tokens' => $request->user()->tokens()->latest()->get(),
|
||||
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
|
||||
'newToken' => session('new_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:60'],
|
||||
]);
|
||||
|
||||
$token = $request->user()->createToken($data['name'], ['mailboxes:read']);
|
||||
|
||||
return redirect()->route('account.developers')
|
||||
->with('new_token', $token->plainTextToken)
|
||||
->with('success', 'Token created — copy it now, it won’t be shown again.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $token): RedirectResponse
|
||||
{
|
||||
$request->user()->tokens()->whereKey($token)->delete();
|
||||
|
||||
return redirect()->route('account.developers')->with('success', 'Token revoked.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Services\Hosting\ResellerClubHostingService;
|
||||
use App\Services\ResellerClub\ResellerClubCustomerService;
|
||||
use App\Services\ResellerClub\ResellerClubProductService;
|
||||
use App\Support\ResellerClubLegacy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostingController extends Controller
|
||||
{
|
||||
public function index(Request $request, ResellerClubHostingService $hosting): RedirectResponse
|
||||
{
|
||||
return redirect()->route('hosting.single-domain');
|
||||
}
|
||||
|
||||
public function show(
|
||||
Request $request,
|
||||
HostingOrder $hostingOrder,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubProductService $products
|
||||
): View
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$remoteDetails = null;
|
||||
if ($hostingOrder->rc_order_id && $hosting->isConfigured()) {
|
||||
$result = $hosting->getOrderDetails($hostingOrder->rc_order_id);
|
||||
if ($result['success'] ?? false) {
|
||||
$remoteDetails = $result['order'];
|
||||
// Sync latest data
|
||||
$hosting->syncOrderToLocal($result['order']['raw'] ?? $result['order'], $request->user()->id, $hostingOrder->domain_id);
|
||||
$hostingOrder->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return view('hosting.show', [
|
||||
'order' => $hostingOrder,
|
||||
'remoteDetails' => $remoteDetails,
|
||||
'renewalOptions' => $this->renewalOptionsForOrder($hostingOrder, $products),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(
|
||||
Request $request,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubCustomerService $customers
|
||||
): RedirectResponse {
|
||||
$user = $request->user();
|
||||
|
||||
if (! $hosting->isConfigured()) {
|
||||
return redirect()->route('hosting.index')->with('error', 'Hosting service is not configured.');
|
||||
}
|
||||
|
||||
$resolved = $customers->resolveCustomerForUser($user);
|
||||
if (! ($resolved['success'] ?? false)) {
|
||||
return redirect()->route('hosting.index')
|
||||
->with('error', $resolved['message'] ?? 'Could not prepare your ResellerClub customer.');
|
||||
}
|
||||
|
||||
$synced = $hosting->syncCustomerOrders((string) $resolved['customer_id'], $user->id);
|
||||
|
||||
return redirect()->route('hosting.index')
|
||||
->with('success', "Synced {$synced} hosting order(s) from ResellerClub.");
|
||||
}
|
||||
|
||||
public function order(
|
||||
Request $request,
|
||||
ResellerClubHostingService $hosting,
|
||||
ResellerClubCustomerService $customers
|
||||
): JsonResponse {
|
||||
$request->validate([
|
||||
'domain_name' => 'required|string|max:253',
|
||||
'plan_id' => 'required|string',
|
||||
'months' => 'sometimes|integer|min:1|max:120',
|
||||
]);
|
||||
|
||||
if (! $hosting->isConfigured()) {
|
||||
return response()->json(['message' => 'Hosting service is not available at this time.'], 503);
|
||||
}
|
||||
|
||||
$domainName = strtolower(trim($request->input('domain_name')));
|
||||
$planId = $request->input('plan_id');
|
||||
$months = $request->integer('months', 1);
|
||||
|
||||
$resolved = $customers->resolveCustomerForUser($request->user());
|
||||
if (! ($resolved['success'] ?? false)) {
|
||||
return response()->json([
|
||||
'message' => $resolved['message'] ?? 'Could not prepare your ResellerClub customer.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$result = $hosting->orderHosting($domainName, $planId, (string) $resolved['customer_id'], $months);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Hosting order failed.'], 422);
|
||||
}
|
||||
|
||||
// Fetch details and save locally
|
||||
$order = null;
|
||||
if (! empty($result['order_id'])) {
|
||||
$details = $hosting->getOrderDetails($result['order_id']);
|
||||
if ($details['success'] ?? false) {
|
||||
$order = $hosting->syncOrderToLocal(
|
||||
$details['order']['raw'] ?? $details['order'],
|
||||
$request->user()->id
|
||||
);
|
||||
} else {
|
||||
$order = HostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'domain_name' => $domainName,
|
||||
'rc_order_id' => $result['order_id'],
|
||||
'plan_name' => $planId,
|
||||
'status' => HostingOrder::STATUS_PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Hosting order placed successfully.',
|
||||
'order' => $order?->only('id', 'domain_name', 'status', 'rc_order_id'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetPanelPassword(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'string', 'min:9', 'max:16', 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[~*!@$#%_+.\?:,{}]).+$/'],
|
||||
]);
|
||||
|
||||
if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) {
|
||||
return response()->json(['message' => 'Hosting service is not available.'], 503);
|
||||
}
|
||||
|
||||
$result = $hosting->changePanelPassword($hostingOrder->rc_order_id, $validated['password']);
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Could not update the cPanel password.'], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'cPanel password updated successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function renew(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): JsonResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'months' => 'sometimes|integer|min:1|max:120',
|
||||
]);
|
||||
|
||||
if (! ResellerClubLegacy::renewalsEnabled()) {
|
||||
return response()->json(['message' => ResellerClubLegacy::renewalsDisabledMessage()], 503);
|
||||
}
|
||||
|
||||
if (! $hosting->isConfigured() || ! $hostingOrder->rc_order_id) {
|
||||
return response()->json(['message' => 'Hosting service is not available.'], 503);
|
||||
}
|
||||
|
||||
$result = $hosting->renewOrder($hostingOrder->rc_order_id, $request->integer('months', 1));
|
||||
|
||||
if (! $result['success']) {
|
||||
return response()->json(['message' => $result['message'] ?? 'Renewal failed.'], 422);
|
||||
}
|
||||
|
||||
// Re-sync details
|
||||
$details = $hosting->getOrderDetails($hostingOrder->rc_order_id);
|
||||
if ($details['success'] ?? false) {
|
||||
$hosting->syncOrderToLocal($details['order']['raw'] ?? $details['order'], $request->user()->id, $hostingOrder->domain_id);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Hosting renewed successfully.']);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, HostingOrder $hostingOrder, ResellerClubHostingService $hosting): RedirectResponse
|
||||
{
|
||||
if ((int) $hostingOrder->user_id !== (int) $request->user()->id) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if ($hostingOrder->status === HostingOrder::STATUS_CANCELLED) {
|
||||
$redirectRoute = $this->productRouteForOrder($hostingOrder);
|
||||
$hostingOrder->delete();
|
||||
|
||||
return redirect()->route($redirectRoute)
|
||||
->with('success', 'Cancelled hosting order deleted.');
|
||||
}
|
||||
|
||||
if ($hostingOrder->rc_order_id && $hosting->isConfigured()) {
|
||||
$result = $hosting->deleteOrder($hostingOrder->rc_order_id);
|
||||
if (! $result['success']) {
|
||||
return redirect()->route('hosting.show', $hostingOrder)
|
||||
->with('error', $result['message'] ?? 'Could not cancel hosting at this time.');
|
||||
}
|
||||
}
|
||||
|
||||
$hostingOrder->update(['status' => HostingOrder::STATUS_CANCELLED]);
|
||||
|
||||
return redirect()->route($this->productRouteForOrder($hostingOrder))
|
||||
->with('success', 'Hosting order cancelled.');
|
||||
}
|
||||
|
||||
private function productRouteForOrder(HostingOrder $hostingOrder): string
|
||||
{
|
||||
return match ((string) $hostingOrder->hosting_type) {
|
||||
HostingOrder::TYPE_MULTI_DOMAIN => 'hosting.multi-domain',
|
||||
HostingOrder::TYPE_RESELLER => 'hosting.multi-domain',
|
||||
default => 'hosting.single-domain',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{months: string, label: string, total: ?string, monthly: ?string}>
|
||||
*/
|
||||
private function renewalOptionsForOrder(HostingOrder $hostingOrder, ResellerClubProductService $products): array
|
||||
{
|
||||
$category = match ((string) $hostingOrder->hosting_type) {
|
||||
HostingOrder::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingOrder::TYPE_RESELLER => 'reseller-hosting',
|
||||
default => 'single-domain-hosting',
|
||||
};
|
||||
|
||||
$packages = collect((array) ($products->formDefinition($category)['packages'] ?? []));
|
||||
$planId = (string) ($hostingOrder->plan_id ?? '');
|
||||
$planName = strtolower(trim((string) $hostingOrder->plan_name));
|
||||
|
||||
$package = $packages->first(function (array $item) use ($planId, $planName): bool {
|
||||
$value = (string) ($item['value'] ?? '');
|
||||
$label = strtolower(trim((string) ($item['label'] ?? '')));
|
||||
|
||||
return ($planId !== '' && $value === $planId)
|
||||
|| ($planName !== '' && $label !== '' && $label === $planName);
|
||||
});
|
||||
|
||||
if (! is_array($package)) {
|
||||
return collect(['1', '3', '6', '12', '24', '36'])
|
||||
->map(fn (string $months) => [
|
||||
'months' => $months,
|
||||
'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'),
|
||||
'total' => null,
|
||||
'monthly' => null,
|
||||
])
|
||||
->all();
|
||||
}
|
||||
|
||||
$priceField = ! empty((array) ($package['renew_prices'] ?? [])) ? 'renew_prices' : 'prices';
|
||||
$totalField = ! empty((array) ($package['renew_totals'] ?? [])) ? 'renew_totals' : 'totals';
|
||||
|
||||
return collect(array_keys((array) ($package[$priceField] ?? [])))
|
||||
->sortBy(fn (string $months) => (int) $months)
|
||||
->values()
|
||||
->map(fn (string $months) => [
|
||||
'months' => $months,
|
||||
'label' => $months.' '.((int) $months === 1 ? 'Month' : 'Months'),
|
||||
'total' => (string) (($package[$totalField][$months] ?? null) ?: ''),
|
||||
'monthly' => (string) (($package[$priceField][$months] ?? null) ?: ''),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Models\HostingAccountMember;
|
||||
use App\Models\HostingOrder;
|
||||
use App\Models\HostingPlanChange;
|
||||
use App\Models\HostingProduct;
|
||||
use App\Models\RcServiceOrder;
|
||||
use App\Services\Hosting\HostingOrderFulfillmentService;
|
||||
use App\Services\Hosting\HostingPlanChangeService;
|
||||
use App\Services\Hosting\HostingRenewalCheckoutService;
|
||||
use App\Services\Hosting\ServerOrderService;
|
||||
use App\Services\Hosting\UpsellRecommendationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class HostingProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Smart hosting redirect - routes user based on their hosting accounts
|
||||
*/
|
||||
public function index(Request $request): View|RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get all shared hosting types (not VPS/dedicated)
|
||||
$sharedTypes = [
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN,
|
||||
HostingProduct::TYPE_WORDPRESS,
|
||||
];
|
||||
|
||||
// Get user's hosting accounts (owned + team member access)
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with('product')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get user's hosting orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->whereIn('type', $sharedTypes))
|
||||
->with(['product', 'hostingAccount'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Combine accounts from orders and direct assignments
|
||||
$allAccounts = $hostingAccounts->merge(
|
||||
$orders->filter(fn ($o) => $o->hostingAccount)->map(fn ($o) => $o->hostingAccount)
|
||||
)->unique('id');
|
||||
|
||||
// No hosting accounts - show selection modal
|
||||
if ($allAccounts->isEmpty()) {
|
||||
return view('hosting.select-type', [
|
||||
'title' => 'Choose Hosting Plan',
|
||||
]);
|
||||
}
|
||||
|
||||
// Single account - redirect directly to panel
|
||||
if ($allAccounts->count() === 1) {
|
||||
$account = $allAccounts->first();
|
||||
return redirect()->route('hosting.panel.index', $account);
|
||||
}
|
||||
|
||||
// Multiple accounts - show list page
|
||||
return view('hosting.accounts-list', [
|
||||
'title' => 'Your Hosting Accounts',
|
||||
'accounts' => $allAccounts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function singleDomain(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_SINGLE_DOMAIN, 'Single Domain Hosting');
|
||||
}
|
||||
|
||||
public function multiDomain(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_MULTI_DOMAIN, 'Multi Domain Hosting');
|
||||
}
|
||||
|
||||
public function wordpress(Request $request): View
|
||||
{
|
||||
return $this->renderHostingPage($request, HostingProduct::TYPE_WORDPRESS, 'WordPress Hosting');
|
||||
}
|
||||
|
||||
public function vps(Request $request): View
|
||||
{
|
||||
return $this->renderServerPage($request, HostingProduct::TYPE_VPS, 'VPS');
|
||||
}
|
||||
|
||||
public function dedicated(Request $request): View
|
||||
{
|
||||
return $this->renderServerPage($request, HostingProduct::TYPE_DEDICATED, 'Dedicated Server');
|
||||
}
|
||||
|
||||
private function renderHostingPage(Request $request, string $type, string $title): View
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// New Hosting Orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'domain', 'hostingAccount', 'hostingNode'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Admin-assigned Hosting Accounts
|
||||
$sharedAccountIds = HostingAccountMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('hosting_account_id');
|
||||
|
||||
$hostingAccounts = HostingAccount::query()
|
||||
->where(function ($query) use ($user, $sharedAccountIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
|
||||
if ($sharedAccountIds->isNotEmpty()) {
|
||||
$query->orWhereIn('id', $sharedAccountIds);
|
||||
}
|
||||
})
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with('product')
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// RC Hosting Orders (Legacy)
|
||||
$legacyHostingType = $this->getLegacyHostingTypeForType($type);
|
||||
$rcCategory = $this->getRcCategoryForType($type);
|
||||
$rcHostingOrders = collect();
|
||||
$rcServiceOrders = collect();
|
||||
|
||||
if ($legacyHostingType) {
|
||||
$rcHostingOrders = HostingOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('hosting_type', $legacyHostingType)
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
if ($rcCategory) {
|
||||
$rcServiceOrders = RcServiceOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('category', $rcCategory)
|
||||
->visibleToCustomer()
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
$marketingCategory = $this->getMarketingOrderCategory($type);
|
||||
|
||||
// Native form data for in-dashboard ordering
|
||||
$nativeForm = $this->nativeHostingProductForm($type);
|
||||
$userDomains = $this->getUserDomains($user);
|
||||
|
||||
return view('hosting.type', [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'orders' => $orders,
|
||||
'hostingAccounts' => $hostingAccounts,
|
||||
'rcHostingOrders' => $rcHostingOrders,
|
||||
'rcServiceOrders' => $rcServiceOrders,
|
||||
'marketingCategory' => $marketingCategory,
|
||||
'nativeForm' => $nativeForm,
|
||||
'userDomains' => $userDomains,
|
||||
]);
|
||||
}
|
||||
|
||||
private function renderServerPage(Request $request, string $type, string $title): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
|
||||
// New Hosting Orders
|
||||
$orders = CustomerHostingOrder::query()
|
||||
->forUser($user->id)
|
||||
->whereHas('product', fn ($q) => $q->ofType($type))
|
||||
->with(['product', 'vpsInstance'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// RC Service Orders (Legacy)
|
||||
$rcCategory = $this->getRcCategoryForType($type);
|
||||
$rcServiceOrders = collect();
|
||||
|
||||
if ($rcCategory) {
|
||||
$rcServiceOrders = RcServiceOrder::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('category', $rcCategory)
|
||||
->visibleToCustomer()
|
||||
->latest()
|
||||
->get();
|
||||
}
|
||||
|
||||
$marketingCategory = $this->getMarketingOrderCategory($type);
|
||||
|
||||
// Native form data for in-dashboard ordering
|
||||
$nativeForm = $this->nativeHostingProductForm($type);
|
||||
$userDomains = $this->getUserDomains($user);
|
||||
$products = HostingProduct::query()
|
||||
->active()
|
||||
->visible()
|
||||
->where('type', $type)
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$serverOrderPayloads = [];
|
||||
foreach ($products as $product) {
|
||||
$serverOrderPayloads[$product->id] = $serverOrders->frontendPayload($product);
|
||||
}
|
||||
|
||||
$billingCycles = [
|
||||
CustomerHostingOrder::CYCLE_MONTHLY => 'Monthly',
|
||||
CustomerHostingOrder::CYCLE_QUARTERLY => 'Quarterly (3 months)',
|
||||
CustomerHostingOrder::CYCLE_SEMIANNUAL => 'Semiannual (6 months)',
|
||||
CustomerHostingOrder::CYCLE_YEARLY => 'Yearly (12 months)',
|
||||
];
|
||||
|
||||
return view('hosting.server-type', [
|
||||
'title' => $title,
|
||||
'type' => $type,
|
||||
'orders' => $orders,
|
||||
'rcServiceOrders' => $rcServiceOrders,
|
||||
'marketingCategory' => $marketingCategory,
|
||||
'nativeForm' => $nativeForm,
|
||||
'serverOrderPayloads' => $serverOrderPayloads,
|
||||
'billingCycles' => $billingCycles,
|
||||
'userDomains' => $userDomains,
|
||||
]);
|
||||
}
|
||||
|
||||
public function showAccount(
|
||||
Request $request,
|
||||
HostingAccount $account,
|
||||
?UpsellRecommendationService $upsells = null,
|
||||
?HostingRenewalCheckoutService $renewals = null
|
||||
): View
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
$upsells ??= app(UpsellRecommendationService::class);
|
||||
$renewals ??= app(HostingRenewalCheckoutService::class);
|
||||
|
||||
$account->load(['product', 'user', 'node', 'sites', 'mailboxes']);
|
||||
$maxDomains = $account->resource_limits['max_domains'] ?? $account->product?->max_domains ?? 1;
|
||||
$freeEmailAllowance = $account->freeEmailAllowance();
|
||||
$mailboxCount = $account->mailboxes->count();
|
||||
$extraMailboxCount = $freeEmailAllowance === null ? 0 : max($mailboxCount - $freeEmailAllowance, 0);
|
||||
|
||||
return view('hosting.account', [
|
||||
'account' => $account,
|
||||
'linkedSites' => $account->sites->sortBy(fn ($site) => [$site->type !== 'primary', $site->domain])->values(),
|
||||
'maxDomains' => $maxDomains,
|
||||
'canLinkMoreDomains' => $account->sites->count() < $maxDomains,
|
||||
'ownedDomains' => $this->getUserDomains($request->user()),
|
||||
'emailUsage' => [
|
||||
'mailbox_count' => $mailboxCount,
|
||||
'free_allowance' => $freeEmailAllowance,
|
||||
'extra_count' => $extraMailboxCount,
|
||||
'paid_count' => $account->mailboxes->where('is_paid', true)->count(),
|
||||
'extra_total' => round($extraMailboxCount * 5, 2),
|
||||
],
|
||||
'upsellRecommendations' => $upsells->recommendationsForAccount($account),
|
||||
'renewalOptions' => $account->canBeRenewed() ? $renewals->availableRenewalOptions($account) : [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function changeAccountPlan(Request $request, HostingAccount $account, HostingPlanChangeService $planChanges): RedirectResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'target_product_id' => ['required', 'integer', 'exists:hosting_products,id'],
|
||||
]);
|
||||
|
||||
$account->load(['product', 'latestOrder', 'sites', 'databases', 'node']);
|
||||
$targetProduct = HostingProduct::query()->findOrFail($validated['target_product_id']);
|
||||
|
||||
try {
|
||||
$quote = $planChanges->quote($account, $targetProduct);
|
||||
$result = $planChanges->apply($account, $targetProduct, $request->user());
|
||||
} catch (ValidationException $exception) {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->withErrors($exception->errors())
|
||||
->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Unable to change the hosting plan right now. Please try again.');
|
||||
}
|
||||
|
||||
$message = match ($quote['direction']) {
|
||||
HostingPlanChange::DIRECTION_UPGRADE => $quote['charge_amount'] > 0
|
||||
? sprintf(
|
||||
'Plan upgraded to %s. A GHS %s invoice has been added for the prorated difference.',
|
||||
$targetProduct->name,
|
||||
number_format((float) $quote['charge_amount'], 2)
|
||||
)
|
||||
: sprintf('Plan upgraded to %s.', $targetProduct->name),
|
||||
HostingPlanChange::DIRECTION_DOWNGRADE => $quote['credit_amount'] > 0
|
||||
? sprintf(
|
||||
'Plan changed to %s. A GHS %s credit has been recorded for the unused balance.',
|
||||
$targetProduct->name,
|
||||
number_format((float) $quote['credit_amount'], 2)
|
||||
)
|
||||
: sprintf('Plan changed to %s.', $targetProduct->name),
|
||||
default => 'Plan updated successfully.',
|
||||
};
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $result['account'])
|
||||
->with('success', $message);
|
||||
}
|
||||
|
||||
public function renewAccount(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse|JsonResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$durationMonths = $request->has('duration_months')
|
||||
? (int) $request->input('duration_months')
|
||||
: null;
|
||||
|
||||
try {
|
||||
$checkout = $renewals->beginCheckout(
|
||||
$account,
|
||||
$request->user(),
|
||||
route('hosting.accounts.renew.callback', $account),
|
||||
$durationMonths
|
||||
);
|
||||
} catch (ValidationException $exception) {
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => $exception->getMessage()], 422);
|
||||
}
|
||||
return back()->withErrors($exception->errors())->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['error' => 'Unable to start renewal payment. Please try again.'], 422);
|
||||
}
|
||||
return back()->with('error', 'Unable to start renewal payment. Please try again.');
|
||||
}
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json(['checkout_url' => $checkout['authorization_url']]);
|
||||
}
|
||||
|
||||
return redirect()->away($checkout['authorization_url']);
|
||||
}
|
||||
|
||||
public function renewAccountCallback(Request $request, HostingAccount $account, HostingRenewalCheckoutService $renewals): RedirectResponse
|
||||
{
|
||||
Gate::forUser($request->user())->authorize('viewAccount', $account);
|
||||
|
||||
$reference = (string) $request->query('reference', '');
|
||||
if ($reference === '') {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Missing renewal payment reference.');
|
||||
}
|
||||
|
||||
try {
|
||||
$invoice = $renewals->completeCheckout($account, $request->user(), $reference);
|
||||
} catch (ValidationException $exception) {
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->withErrors($exception->errors())
|
||||
->with('error', $exception->getMessage());
|
||||
} catch (\Throwable $exception) {
|
||||
report($exception);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('error', 'Unable to verify renewal payment. If you were charged, contact support with your payment reference.');
|
||||
}
|
||||
|
||||
$months = (int) data_get($invoice->metadata, 'months', $account->assignedDurationMonths() ?? 0);
|
||||
|
||||
return redirect()
|
||||
->route('hosting.accounts.show', $account)
|
||||
->with('success', "Hosting renewal payment confirmed. Your account has been renewed for {$months} month(s).");
|
||||
}
|
||||
|
||||
public function show(Request $request, CustomerHostingOrder $order): View
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
|
||||
$order->load(['product', 'domain', 'hostingAccount', 'hostingNode', 'vpsInstance']);
|
||||
|
||||
$viewName = $order->product?->requiresContabo() ? 'hosting.server-order' : 'hosting.order';
|
||||
|
||||
return view($viewName, [
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:hosting_products,id',
|
||||
'domain_name' => 'required|string|max:255',
|
||||
'billing_cycle' => 'required|in:monthly,quarterly,yearly,biennial',
|
||||
]);
|
||||
|
||||
$product = HostingProduct::findOrFail($validated['product_id']);
|
||||
|
||||
abort_unless($product->is_active && $product->is_visible, 404, 'Product not available');
|
||||
|
||||
$price = $product->getPriceForCycle($validated['billing_cycle']);
|
||||
abort_unless($price !== null, 400, 'Invalid billing cycle for this product');
|
||||
|
||||
$order = CustomerHostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'domain_name' => $validated['domain_name'],
|
||||
'billing_cycle' => $validated['billing_cycle'],
|
||||
'amount_paid' => $price + ($product->setup_fee ?? 0),
|
||||
'currency' => $product->display_currency,
|
||||
'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
]);
|
||||
|
||||
$fulfillment = app(HostingOrderFulfillmentService::class);
|
||||
$fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled'];
|
||||
$customerMessage = $fulfilled
|
||||
? 'Order received. We are setting up your hosting.'
|
||||
: 'Order received. Your order is pending.';
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $customerMessage,
|
||||
'order' => $order->fresh(),
|
||||
'redirect' => route('hosting.orders.show', $order),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('hosting.orders.show', $order)
|
||||
->with('success', $customerMessage);
|
||||
}
|
||||
|
||||
public function storeServer(Request $request): JsonResponse|RedirectResponse
|
||||
{
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:hosting_products,id',
|
||||
'server_name' => 'required|string|max:255',
|
||||
'billing_cycle' => 'required|in:monthly,quarterly,semiannual,yearly',
|
||||
'region' => 'required|string|max:50',
|
||||
'image' => 'required|string|max:255',
|
||||
'license' => 'required|string|max:100',
|
||||
'application' => 'required|string|max:100',
|
||||
'default_user' => 'required|string|max:50',
|
||||
'additional_ip' => 'required|string|max:50',
|
||||
'private_networking' => 'required|string|max:50',
|
||||
'storage_type' => 'required|string|max:50',
|
||||
'object_storage' => 'required|string|max:50',
|
||||
'server_password' => ['required', 'string', 'confirmed', 'regex:'.$serverOrders->passwordPattern()],
|
||||
]);
|
||||
|
||||
$product = HostingProduct::findOrFail($validated['product_id']);
|
||||
|
||||
abort_unless($product->is_active && $product->is_visible, 404, 'Product not available');
|
||||
abort_unless($product->requiresContabo(), 400, 'Invalid product type for server order');
|
||||
|
||||
$selection = $serverOrders->selectionAndQuote($product, $validated);
|
||||
$quote = $selection['quote'];
|
||||
|
||||
$order = CustomerHostingOrder::create([
|
||||
'user_id' => $request->user()->id,
|
||||
'hosting_product_id' => $product->id,
|
||||
'domain_name' => $validated['server_name'],
|
||||
'billing_cycle' => $validated['billing_cycle'],
|
||||
'amount_paid' => (float) $quote['total'],
|
||||
'currency' => $product->display_currency,
|
||||
'status' => CustomerHostingOrder::STATUS_PENDING_APPROVAL,
|
||||
'meta' => [
|
||||
'region' => $selection['selection']['region'],
|
||||
'server_order' => [
|
||||
'selection' => $selection['selection'],
|
||||
'selection_summary' => $selection['selection_summary'],
|
||||
'quote' => $quote,
|
||||
'panel_snapshot' => $selection['panel_snapshot'] ?? [],
|
||||
'server_password_encrypted' => encrypt($validated['server_password']),
|
||||
],
|
||||
'server_panel_runtime' => $selection['panel_snapshot'] ?? [],
|
||||
],
|
||||
]);
|
||||
|
||||
$fulfillment = app(HostingOrderFulfillmentService::class);
|
||||
$fulfilled = $fulfillment->attemptAutoFulfillment($order)['fulfilled'];
|
||||
$customerMessage = $fulfilled
|
||||
? 'Order received. We are setting up your server.'
|
||||
: 'Order received. Your order is pending.';
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => $customerMessage,
|
||||
'order' => $order->fresh(),
|
||||
'redirect' => route('hosting.orders.show', $order),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('hosting.orders.show', $order)
|
||||
->with('success', $customerMessage);
|
||||
}
|
||||
|
||||
public function cancel(Request $request, CustomerHostingOrder $order): JsonResponse|RedirectResponse
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
abort_unless($order->canBeCancelled(), 400, 'This order cannot be cancelled');
|
||||
|
||||
$order->cancel($request->input('reason'));
|
||||
|
||||
if ($request->wantsJson()) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Order cancelled successfully.',
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Order cancelled successfully.');
|
||||
}
|
||||
|
||||
private function getLegacyHostingTypeForType(string $type): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => HostingOrder::TYPE_SINGLE_DOMAIN,
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => HostingOrder::TYPE_MULTI_DOMAIN,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getRcCategoryForType(string $type): ?string
|
||||
{
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting',
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting',
|
||||
HostingProduct::TYPE_VPS => 'vps',
|
||||
HostingProduct::TYPE_DEDICATED => 'dedicated-server',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function getMarketingOrderCategory(string $type): ?string
|
||||
{
|
||||
// Map internal hosting types to marketing order categories
|
||||
return match ($type) {
|
||||
HostingProduct::TYPE_SINGLE_DOMAIN => 'single-domain-hosting',
|
||||
HostingProduct::TYPE_MULTI_DOMAIN => 'multi-domain-hosting',
|
||||
HostingProduct::TYPE_WORDPRESS => 'wordpress-hosting',
|
||||
HostingProduct::TYPE_VPS => 'vps',
|
||||
HostingProduct::TYPE_DEDICATED => 'dedicated-server',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build native form definition from HostingProduct records.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function nativeHostingProductForm(string $productType): array
|
||||
{
|
||||
$products = HostingProduct::query()
|
||||
->active()
|
||||
->visible()
|
||||
->where('type', $productType)
|
||||
->ordered()
|
||||
->get();
|
||||
|
||||
$serverOrders = app(ServerOrderService::class);
|
||||
|
||||
$packages = $products->map(function (HostingProduct $product) use ($serverOrders) {
|
||||
if ($product->requiresContabo()) {
|
||||
$payload = $serverOrders->frontendPayload($product);
|
||||
$currency = strtoupper((string) ($payload['currency'] ?? $product->display_currency));
|
||||
$monthly = (float) data_get($payload, 'prices.monthly', 0);
|
||||
$quarterly = (float) data_get($payload, 'prices.quarterly', 0);
|
||||
$semiannual = (float) data_get($payload, 'prices.semiannual', 0);
|
||||
$yearly = (float) data_get($payload, 'prices.yearly', 0);
|
||||
$setupFees = (array) ($payload['setup_fees'] ?? []);
|
||||
|
||||
$prices = [];
|
||||
$totals = [];
|
||||
|
||||
if ($monthly > 0) {
|
||||
$prices['1'] = $currency.' '.number_format($monthly, 2);
|
||||
$totals['1'] = $currency.' '.number_format($monthly + (float) ($setupFees['monthly'] ?? 0), 2);
|
||||
}
|
||||
if ($quarterly > 0) {
|
||||
$prices['3'] = $currency.' '.number_format($quarterly / 3, 2);
|
||||
$totals['3'] = $currency.' '.number_format($quarterly + (float) ($setupFees['quarterly'] ?? 0), 2);
|
||||
}
|
||||
if ($semiannual > 0) {
|
||||
$prices['6'] = $currency.' '.number_format($semiannual / 6, 2);
|
||||
$totals['6'] = $currency.' '.number_format($semiannual + (float) ($setupFees['semiannual'] ?? 0), 2);
|
||||
}
|
||||
if ($yearly > 0) {
|
||||
$prices['12'] = $currency.' '.number_format($yearly / 12, 2);
|
||||
$totals['12'] = $currency.' '.number_format($yearly + (float) ($setupFees['yearly'] ?? 0), 2);
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => (string) $product->id,
|
||||
'label' => $product->name,
|
||||
'summary' => $this->nativeProductSummary($product),
|
||||
'starting_price' => $currency.' '.number_format($monthly, 2),
|
||||
'starting_term' => '1',
|
||||
'starting_label' => $currency.' '.number_format($monthly, 2).'/mo',
|
||||
'prices' => $prices,
|
||||
'totals' => $totals,
|
||||
'pricing_source' => 'contabo_dynamic',
|
||||
];
|
||||
}
|
||||
|
||||
$currency = $product->display_currency;
|
||||
$monthly = $product->getPriceForCycle('monthly');
|
||||
$quarterly = $product->getPriceForCycle('quarterly');
|
||||
$yearly = $product->getPriceForCycle('yearly');
|
||||
$biennial = $product->getPriceForCycle('biennial');
|
||||
|
||||
$prices = [];
|
||||
$totals = [];
|
||||
|
||||
if ($monthly) {
|
||||
$prices['1'] = $currency.' '.number_format($monthly, 2);
|
||||
$totals['1'] = $currency.' '.number_format($monthly, 2);
|
||||
}
|
||||
if ($quarterly) {
|
||||
$prices['3'] = $currency.' '.number_format($quarterly / 3, 2);
|
||||
$totals['3'] = $currency.' '.number_format($quarterly, 2);
|
||||
}
|
||||
if ($yearly) {
|
||||
$prices['12'] = $currency.' '.number_format($yearly / 12, 2);
|
||||
$totals['12'] = $currency.' '.number_format($yearly, 2);
|
||||
}
|
||||
if ($biennial) {
|
||||
$prices['24'] = $currency.' '.number_format($biennial / 24, 2);
|
||||
$totals['24'] = $currency.' '.number_format($biennial, 2);
|
||||
}
|
||||
|
||||
$summary = $this->nativeProductSummary($product);
|
||||
|
||||
return [
|
||||
'value' => (string) $product->id,
|
||||
'label' => $product->name,
|
||||
'summary' => $summary,
|
||||
'starting_price' => $currency.' '.number_format($product->display_price_monthly, 2),
|
||||
'starting_term' => '1',
|
||||
'starting_label' => $currency.' '.number_format($product->display_price_monthly, 2).'/mo',
|
||||
'prices' => $prices,
|
||||
'totals' => $totals,
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'packages' => $packages,
|
||||
'has_packages' => $products->isNotEmpty(),
|
||||
'region' => 'Global',
|
||||
'requires_domain' => true,
|
||||
'supports_os' => false,
|
||||
'os_options' => [],
|
||||
'supports_addons' => false,
|
||||
'addon_options' => [],
|
||||
'supports_ssl_toggle' => false,
|
||||
'supports_dedicated_ip' => false,
|
||||
];
|
||||
}
|
||||
|
||||
private function nativeProductSummary(HostingProduct $product): ?string
|
||||
{
|
||||
return $product->catalogSummary();
|
||||
}
|
||||
|
||||
private function getUserDomains($user): \Illuminate\Support\Collection
|
||||
{
|
||||
$websiteIds = \App\Models\Website::query()
|
||||
->where('owner_user_id', $user->id)
|
||||
->pluck('id')
|
||||
->merge(
|
||||
\App\Models\WebsiteMember::query()
|
||||
->where('user_id', $user->id)
|
||||
->pluck('website_id')
|
||||
)
|
||||
->unique();
|
||||
|
||||
return \App\Models\Domain::query()
|
||||
->where(function ($query) use ($user, $websiteIds) {
|
||||
$query->where('user_id', $user->id);
|
||||
|
||||
if ($websiteIds->isNotEmpty()) {
|
||||
$query->orWhereIn('website_id', $websiteIds);
|
||||
}
|
||||
})
|
||||
->whereNull('hosting_account_id')
|
||||
->whereDoesntHave('hostedSites')
|
||||
->where(function ($query) {
|
||||
$query->where('source', 'purchased')
|
||||
->orWhereNotNull('email_domain_id');
|
||||
})
|
||||
->orderBy('host')
|
||||
->get(['id', 'host', 'status', 'onboarding_state']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingAccount;
|
||||
use App\Services\Hosting\BrowserTerminalSessionManager;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HostingTerminalSessionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private BrowserTerminalSessionManager $sessionManager,
|
||||
) {}
|
||||
|
||||
public function start(Request $request, HostingAccount $account): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
$account->loadMissing('node');
|
||||
|
||||
$validated = $request->validate([
|
||||
'path' => 'nullable|string|max:255',
|
||||
'cols' => 'nullable|integer|min:40|max:240',
|
||||
'rows' => 'nullable|integer|min:10|max:80',
|
||||
]);
|
||||
|
||||
$session = $this->sessionManager->createSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
(string) ($validated['path'] ?? '/public_html'),
|
||||
(int) ($validated['cols'] ?? 120),
|
||||
(int) ($validated['rows'] ?? 30),
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'session_id' => $session['session_id'],
|
||||
'status' => $session['status'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function read(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
try {
|
||||
$payload = $this->sessionManager->readOutput(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
(int) $request->integer('offset', 0),
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
public function input(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
$input = $this->extractTerminalInput($request);
|
||||
|
||||
if ($input === null) {
|
||||
return response()->json(['message' => 'Invalid terminal input payload.'], 422);
|
||||
}
|
||||
|
||||
if (strlen($input) > 8192) {
|
||||
return response()->json(['message' => 'Terminal input payload is too large.'], 422);
|
||||
}
|
||||
|
||||
if ($input === '') {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->sessionManager->appendInput(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
$input,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function resize(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'cols' => 'required|integer|min:40|max:240',
|
||||
'rows' => 'required|integer|min:10|max:80',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->sessionManager->resizeSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
(int) $validated['cols'],
|
||||
(int) $validated['rows'],
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function close(Request $request, HostingAccount $account, string $session): JsonResponse
|
||||
{
|
||||
$this->authorize('useTerminal', $account);
|
||||
|
||||
try {
|
||||
$this->sessionManager->closeSession(
|
||||
$account,
|
||||
(int) $request->user()->id,
|
||||
$session,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 404);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
private function extractTerminalInput(Request $request): ?string
|
||||
{
|
||||
$input = $request->input('input');
|
||||
|
||||
if (is_string($input)) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
$raw = $request->getContent();
|
||||
|
||||
if (! is_string($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
|
||||
if (is_array($decoded) && array_key_exists('input', $decoded) && is_string($decoded['input'])) {
|
||||
return $decoded['input'];
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MailboxLinkBannerController extends Controller
|
||||
{
|
||||
public function dismiss(Request $request): RedirectResponse
|
||||
{
|
||||
$request->session()->put('mailbox_link_banner_dismissed', true);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function index(Request $request): RedirectResponse
|
||||
{
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CustomerHostingOrder;
|
||||
use App\Services\Hosting\PanelRuntimes\ServerAgentPanelRuntime;
|
||||
use App\Services\Hosting\ServerAgentBootstrapService;
|
||||
use App\Services\Hosting\Providers\ContaboProvider;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ServerPanelController extends Controller
|
||||
{
|
||||
public function show(Request $request, CustomerHostingOrder $order): View
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
$panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', []));
|
||||
|
||||
if ((bool) ($panelRuntime['managed_stack_candidate'] ?? false)) {
|
||||
app(ServerAgentBootstrapService::class)->ensureBootstrap($order);
|
||||
$order->refresh();
|
||||
$panelRuntime = (array) data_get($order->meta, 'server_panel_runtime', data_get($order->meta, 'server_order.panel_snapshot', []));
|
||||
}
|
||||
|
||||
return view('hosting.server-panel.show', [
|
||||
'order' => $order,
|
||||
'panelState' => (array) data_get($order->meta, 'server_panel', []),
|
||||
'panelRuntime' => $panelRuntime,
|
||||
'selectionSummary' => (array) data_get($order->meta, 'server_order.selection_summary', []),
|
||||
'serverAgent' => $order->serverAgent,
|
||||
'serverTasks' => $order->serverTasks()->latest('id')->limit(12)->get(),
|
||||
'serverSites' => $order->serverSites()->latest('updated_at')->limit(6)->get(),
|
||||
'serverDatabases' => $order->serverDatabases()->latest('updated_at')->limit(6)->get(),
|
||||
'serverFiles' => $order->serverFiles()->latest('last_synced_at')->limit(8)->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function sync(Request $request, CustomerHostingOrder $order, ContaboProvider $provider): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
$instance = $provider->getInstance($this->providerInstanceId($order));
|
||||
|
||||
abort_if($instance === null, 404, 'Server instance not found.');
|
||||
|
||||
$this->updateOrderState($order, $instance);
|
||||
|
||||
return back()->with('success', 'Server status refreshed.');
|
||||
}
|
||||
|
||||
public function power(Request $request, CustomerHostingOrder $order, string $action, ContaboProvider $provider): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
|
||||
abort_unless(in_array($action, ['start', 'stop', 'reboot'], true), 404);
|
||||
|
||||
$instanceId = $this->providerInstanceId($order);
|
||||
$success = match ($action) {
|
||||
'start' => $provider->startInstance($instanceId),
|
||||
'stop' => $provider->stopInstance($instanceId),
|
||||
'reboot' => $provider->rebootInstance($instanceId),
|
||||
};
|
||||
|
||||
if (! $success) {
|
||||
return back()->with('error', 'Server action failed. Please try again.');
|
||||
}
|
||||
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$panelState = (array) ($meta['server_panel'] ?? []);
|
||||
$panelState['last_action'] = [
|
||||
'name' => $action,
|
||||
'at' => now()->toIso8601String(),
|
||||
];
|
||||
$panelState['power_status'] = match ($action) {
|
||||
'stop' => 'off',
|
||||
default => 'on',
|
||||
};
|
||||
|
||||
$order->update([
|
||||
'meta' => array_merge($meta, ['server_panel' => $panelState]),
|
||||
]);
|
||||
|
||||
return back()->with('success', ucfirst($action).' request sent successfully.');
|
||||
}
|
||||
|
||||
public function queueDomain(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
'document_root' => ['nullable', 'string', 'max:255'],
|
||||
'php_version' => ['nullable', 'string', 'in:8.0,8.1,8.2,8.3,8.4'],
|
||||
]);
|
||||
|
||||
$runtime->queueDomainTask(
|
||||
$order,
|
||||
$validated['domain'],
|
||||
$validated['document_root'] ?? null,
|
||||
$validated['php_version'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', 'Domain task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueDatabase(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:32', 'regex:/^[a-zA-Z0-9_]+$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueDatabaseTask($order, $validated['name']);
|
||||
|
||||
return back()->with('success', 'Database task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueSsl(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueSslTask($order, $validated['domain']);
|
||||
|
||||
return back()->with('success', 'SSL task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueFile(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'operation' => ['required', 'string', 'in:list,read,write'],
|
||||
'path' => ['required', 'string', 'max:1000'],
|
||||
'content' => ['nullable', 'string'],
|
||||
]);
|
||||
|
||||
$runtime->queueFileTask(
|
||||
$order,
|
||||
$validated['operation'],
|
||||
$validated['path'],
|
||||
$validated['content'] ?? null,
|
||||
);
|
||||
|
||||
return back()->with('success', 'File task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueSiteDelete(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
|
||||
$runtime->queueSiteDeleteTask($order, $validated['domain']);
|
||||
|
||||
return back()->with('success', 'Site removal task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queuePhp(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]+\.[a-zA-Z]{2,}$/'],
|
||||
'php_version' => ['required', 'string', 'in:8.0,8.1,8.2,8.3,8.4'],
|
||||
]);
|
||||
|
||||
$runtime->queuePhpTask($order, $validated['domain'], $validated['php_version']);
|
||||
|
||||
return back()->with('success', 'PHP configuration task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueCron(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'operation' => ['required', 'string', 'in:list,upsert,remove'],
|
||||
'name' => ['nullable', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'],
|
||||
'expression' => ['nullable', 'string', 'max:120'],
|
||||
'command' => ['nullable', 'string', 'max:1000'],
|
||||
'user' => ['nullable', 'string', 'in:web,root,www-data,nginx,wwwrun,apache'],
|
||||
]);
|
||||
|
||||
if (($validated['operation'] ?? '') !== 'list') {
|
||||
$request->validate([
|
||||
'name' => ['required', 'string', 'max:80', 'regex:/^[a-zA-Z0-9._-]+$/'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (($validated['operation'] ?? '') === 'upsert') {
|
||||
$request->validate([
|
||||
'expression' => ['required', 'string', 'max:120'],
|
||||
'command' => ['required', 'string', 'max:1000'],
|
||||
]);
|
||||
}
|
||||
|
||||
$runtime->queueCronTask(
|
||||
$order,
|
||||
$validated['operation'],
|
||||
$validated['name'] ?? null,
|
||||
$validated['expression'] ?? null,
|
||||
$validated['command'] ?? null,
|
||||
$validated['user'] ?? 'web',
|
||||
);
|
||||
|
||||
return back()->with('success', 'Cron task queued for the server agent.');
|
||||
}
|
||||
|
||||
public function queueLog(Request $request, CustomerHostingOrder $order, ServerAgentPanelRuntime $runtime): RedirectResponse
|
||||
{
|
||||
$order = $this->authorizedManagedOrder($request, $order);
|
||||
|
||||
$validated = $request->validate([
|
||||
'target' => ['required', 'string', 'in:nginx_access,nginx_error,php_fpm,mariadb,agent_service'],
|
||||
'domain' => ['nullable', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'],
|
||||
'lines' => ['nullable', 'integer', 'min:20', 'max:500'],
|
||||
]);
|
||||
|
||||
if (in_array($validated['target'], ['nginx_access', 'nginx_error'], true)) {
|
||||
$request->validate([
|
||||
'domain' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9-_.]*\.[a-zA-Z]{2,}$/'],
|
||||
]);
|
||||
}
|
||||
|
||||
$runtime->queueLogTask(
|
||||
$order,
|
||||
$validated['target'],
|
||||
$validated['domain'] ?? null,
|
||||
(int) ($validated['lines'] ?? 120),
|
||||
);
|
||||
|
||||
return back()->with('success', 'Log read task queued for the server agent.');
|
||||
}
|
||||
|
||||
private function authorizedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder
|
||||
{
|
||||
abort_if($order->user_id !== $request->user()->id, 403);
|
||||
|
||||
$order->load('product');
|
||||
abort_unless($order->product?->requiresContabo(), 404);
|
||||
abort_unless($this->ladillPanelEnabled($order), 404);
|
||||
|
||||
return $order;
|
||||
}
|
||||
|
||||
private function ladillPanelEnabled(CustomerHostingOrder $order): bool
|
||||
{
|
||||
$license = (string) data_get($order->meta, 'server_order.selection.license', '');
|
||||
|
||||
return (string) data_get(config('hosting.server_order.licenses'), $license.'.panel') === 'ladill';
|
||||
}
|
||||
|
||||
private function providerInstanceId(CustomerHostingOrder $order): string
|
||||
{
|
||||
$instanceId = (string) data_get($order->meta, 'contabo_instance_id', '');
|
||||
abort_if($instanceId === '', 404, 'Server instance is not provisioned yet.');
|
||||
|
||||
return $instanceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $instance
|
||||
*/
|
||||
private function updateOrderState(CustomerHostingOrder $order, array $instance): void
|
||||
{
|
||||
$meta = (array) ($order->meta ?? []);
|
||||
$panelState = (array) ($meta['server_panel'] ?? []);
|
||||
|
||||
$panelState = array_merge($panelState, [
|
||||
'instance_status' => $instance['status'] ?? null,
|
||||
'power_status' => $instance['power_status'] ?? null,
|
||||
'region' => $instance['region'] ?? null,
|
||||
'datacenter' => $instance['datacenter'] ?? null,
|
||||
'image' => $instance['image'] ?? null,
|
||||
'last_synced_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$order->update([
|
||||
'server_ip' => $instance['ip_address'] ?? $order->server_ip,
|
||||
'meta' => array_merge($meta, ['server_panel' => $panelState]),
|
||||
]);
|
||||
}
|
||||
|
||||
private function authorizedManagedOrder(Request $request, CustomerHostingOrder $order): CustomerHostingOrder
|
||||
{
|
||||
$order = $this->authorizedOrder($request, $order);
|
||||
abort_unless((bool) data_get($order->meta, 'server_panel_runtime.managed_stack_candidate', false), 404);
|
||||
|
||||
return $order;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Hosting;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\HostingTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Ladill Email — Team. Invite teammates to an account and manage their roles;
|
||||
* members can then manage that account's mailboxes & domains. Members accept by
|
||||
* signing into Email with the invited email (SSO). Owner and admins can manage.
|
||||
*/
|
||||
class TeamController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
$members = HostingTeamMember::query()
|
||||
->where('account_id', $account->id)
|
||||
->with('member')
|
||||
->orderBy('status')->orderBy('email')
|
||||
->get();
|
||||
|
||||
return view('hosting.account.team', [
|
||||
'account' => $account,
|
||||
'members' => $members,
|
||||
'canManage' => $this->canManage($request),
|
||||
'isOwner' => $request->user()->id === $account->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request), 403);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'role' => ['required', 'in:admin,member'],
|
||||
]);
|
||||
|
||||
$account = ladill_account();
|
||||
$email = strtolower($validated['email']);
|
||||
|
||||
if ($email === strtolower($account->email)) {
|
||||
return back()->withErrors(['email' => 'The owner is already on the account.']);
|
||||
}
|
||||
|
||||
$member = HostingTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
|
||||
$member->role = $validated['role'];
|
||||
if (! $member->exists) {
|
||||
$member->status = HostingTeamMember::STATUS_INVITED;
|
||||
$member->token = Str::random(40);
|
||||
}
|
||||
$member->save();
|
||||
|
||||
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
|
||||
HostingTeamMember::linkPendingInvitesFor($existing);
|
||||
}
|
||||
|
||||
return back()->with('success', $email.' invited.');
|
||||
}
|
||||
|
||||
public function updateRole(Request $request, HostingTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
|
||||
$member->update(['role' => $validated['role']]);
|
||||
|
||||
return back()->with('success', 'Role updated.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, HostingTeamMember $member): RedirectResponse
|
||||
{
|
||||
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
|
||||
|
||||
$member->delete();
|
||||
|
||||
return back()->with('success', 'Member removed.');
|
||||
}
|
||||
|
||||
/** Switch the acting account (own, or one you're a member of). */
|
||||
public function switchAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate(['account' => ['required', 'integer']]);
|
||||
|
||||
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
|
||||
|
||||
$request->session()->put('ladill_account', (int) $validated['account']);
|
||||
|
||||
return redirect()->route('hosting.dashboard');
|
||||
}
|
||||
|
||||
private function canManage(Request $request): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
$account = ladill_account();
|
||||
|
||||
if ($user->id === $account->id) {
|
||||
return true; // owner
|
||||
}
|
||||
|
||||
return $user->memberships()
|
||||
->where('account_id', $account->id)
|
||||
->where('role', HostingTeamMember::ROLE_ADMIN)
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the "acting account" (owner User) the request operates on. Defaults
|
||||
* to the authenticated user; a team member who switched accounts (session
|
||||
* ladill_account) gets that owner, after an access check. Exposed via request
|
||||
* attribute (ladill_account()) and to views.
|
||||
*/
|
||||
class SetActingAccount
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($user = $request->user()) {
|
||||
$accountId = (int) $request->session()->get('ladill_account', $user->id);
|
||||
|
||||
if (! $user->canAccessAccount($accountId)) {
|
||||
$accountId = $user->id;
|
||||
$request->session()->put('ladill_account', $accountId);
|
||||
}
|
||||
|
||||
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
|
||||
|
||||
$request->attributes->set('actingAccount', $account);
|
||||
View::share('actingAccount', $account);
|
||||
View::share('accessibleAccounts', $user->accessibleAccounts());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user