Files
ladill-hosting/app/Services/Hosting/ResellerClubHostingService.php
T
isaaccladandCursor e3fd235139
Deploy Ladill Hosting / deploy (push) Successful in 26s
Unify SSO and surface legacy ResellerClub hosting orders on the dashboard.
Add silent OAuth and session keepalive, and show imported RC orders alongside
native hosting accounts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 06:33:16 +00:00

663 lines
25 KiB
PHP

<?php
namespace App\Services\Hosting;
use App\Models\HostingOrder;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubHostingService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private ?string $lastSyncError = null;
private string $apiUrl;
private string $hostingEndpoint;
private string $authUserId;
private string $apiKey;
private string $customerId;
public function __construct()
{
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
$this->hostingEndpoint = trim((string) config('mailinfra.hosting_endpoint', 'singledomainhosting/linux/us'), '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
$this->customerId = (string) config('mailinfra.registrar_customer_id');
}
public function isConfigured(): bool
{
return $this->authUserId !== '' && $this->apiKey !== '';
}
public function lastSyncError(): ?string
{
return $this->lastSyncError;
}
/**
* Build a URL with all parameters as query string values, including auth.
*
* @param array<string, mixed> $params
*/
private function buildQueryUrl(string $endpoint, array $params = []): string
{
$all = array_merge([
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
], $params);
$separator = str_contains($endpoint, '?') ? '&' : '?';
return $endpoint.$separator.http_build_query($all);
}
/**
* Authenticate a ResellerClub customer by email and password.
* Returns the customer ID on success, or null on failure.
*
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function authenticateCustomer(string $email, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->apiUrl.'/customers/authenticate.json', [
'username' => $email,
'passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Authentication failed.';
return ['success' => false, 'message' => $message];
}
// RC returns the customer ID directly as the response body on success
$customerId = is_array($data) ? (string) ($data['customerid'] ?? '') : (string) $data;
if ($customerId === '' || $customerId === 'false') {
return ['success' => false, 'message' => 'Invalid email or password.'];
}
return ['success' => true, 'customer_id' => $customerId];
} catch (\Throwable $e) {
Log::error('HostingService: authenticateCustomer failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not verify ResellerClub credentials.'];
}
}
/**
* Get customer details by customer ID.
*
* @return array{success: bool, customer?: array, message?: string}
*/
public function getCustomerDetails(string $customerId): array
{
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/details-by-id.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => $customerId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
return ['success' => true, 'customer' => $data];
} catch (\Throwable $e) {
Log::error('HostingService: getCustomerDetails failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch customer details.'];
}
}
/**
* Search hosting orders for a specific customer.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrdersByCustomer(string $customerId, int $page = 1, int $perPage = 50): array
{
$this->lastSyncError = null;
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
'customer-id' => $customerId,
];
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
$this->lastSyncError = $message;
Log::warning('HostingService: hosting search unsuccessful response', [
'customer_id' => $customerId,
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrdersByCustomer failed', ['error' => $e->getMessage()]);
$message = $this->syncErrorMessage($e, 'Could not fetch hosting orders.');
$this->lastSyncError = $message;
return ['success' => false, 'message' => $message];
}
}
/**
* Sync all orders for a specific RC customer into a Ladill user account.
*
* @return int Number of orders synced.
*/
public function syncCustomerOrders(string $customerId, int $userId): int
{
$this->lastSyncError = null;
$page = 1;
$synced = 0;
do {
$result = $this->searchOrdersByCustomer($customerId, $page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$payload = is_array($order) ? ($order['raw'] ?? $order) : [];
$orderId = (string) ($payload['orderid'] ?? $payload['entityid'] ?? $order['order_id'] ?? '');
if ($orderId !== '') {
$details = $this->getOrderDetails($orderId);
if (($details['success'] ?? false) && is_array($details['order'] ?? null)) {
$payload = (array) ($details['order']['raw'] ?? $details['order']);
}
}
$this->syncOrderToLocal((array) $payload, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
/**
* Search hosting orders under the reseller account.
*
* @return array{success: bool, orders?: array, total?: int, message?: string}
*/
public function searchOrders(int $page = 1, int $perPage = 25, ?string $domainName = null, ?string $status = null): array
{
try {
$params = [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'no-of-records' => $perPage,
'page-no' => $page,
];
if ($domainName) {
$params['domain-name'] = $domainName;
}
if ($status) {
$params['status'] = $status;
}
if ($this->customerId) {
$params['customer-id'] = $this->customerId;
}
$response = Http::timeout(15)->get($this->endpoint('search'), $params);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
$message = $this->responseMessage($response, $data, 'Failed to search hosting orders.');
Log::warning('HostingService: hosting search unsuccessful response', [
'status' => $response->status(),
'body' => $this->sanitizeErrorText((string) $response->body()),
]);
return ['success' => false, 'message' => $message];
}
$total = (int) ($data['recsonpage'] ?? 0);
$orders = [];
// RC returns numbered keys for each record
$recordCount = (int) ($data['recsindb'] ?? 0);
for ($i = 1; $i <= $total; $i++) {
if (isset($data[(string) $i])) {
$orders[] = $this->normalizeOrderData($data[(string) $i]);
}
}
return [
'success' => true,
'orders' => $orders,
'total' => $recordCount,
];
} catch (\Throwable $e) {
Log::error('HostingService: searchOrders failed', ['error' => $e->getMessage()]);
return ['success' => false, 'message' => 'Could not fetch hosting orders.'];
}
}
/**
* Get details of a specific hosting order by order ID.
*
* @return array{success: bool, order?: array, message?: string}
*/
public function getOrderDetails(string $orderId): array
{
try {
$response = Http::timeout(15)->get($this->endpoint('details'), [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'order-id' => $orderId,
]);
$data = $response->json();
if ($response->failed() || ! is_array($data)) {
return ['success' => false, 'message' => 'Failed to fetch order details.'];
}
return [
'success' => true,
'order' => $this->normalizeOrderData($data),
];
} catch (\Throwable $e) {
Log::error('HostingService: getOrderDetails failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not fetch order details.'];
}
}
private function configuredApiUrl(): string
{
$value = trim((string) config('mailinfra.hosting_api_url'));
return $value !== '' ? $value : self::DEFAULT_API_URL;
}
private function endpoint(string $action): string
{
return sprintf('%s/%s/%s.json', $this->apiUrl, $this->hostingEndpoint, $action);
}
/**
* Order a new single-domain Linux hosting plan.
*
* @return array{success: bool, order_id?: string, message?: string}
*/
public function orderHosting(string $domainName, string $planId, string $customerId, int $months = 1): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('add'), [
'domain-name' => $domainName,
'customer-id' => $customerId,
'months' => $months,
'plan-id' => $planId,
'invoice-option' => 'NoInvoice',
]));
$data = $response->json();
if ($response->failed()) {
$message = $data['message'] ?? $data['error'] ?? 'Hosting order failed.';
Log::warning('HostingService: orderHosting failed', [
'domain' => $domainName,
'status' => $response->status(),
'response' => $data,
]);
return ['success' => false, 'message' => $message];
}
Log::info('HostingService: hosting ordered', [
'domain' => $domainName,
'order_id' => $data['entityid'] ?? null,
]);
return [
'success' => true,
'order_id' => (string) ($data['entityid'] ?? ''),
];
} catch (\Throwable $e) {
Log::error('HostingService: orderHosting exception', [
'domain' => $domainName,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Hosting order failed due to a network error.'];
}
}
/**
* Renew a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function renewOrder(string $orderId, int $months = 1, string $invoiceOption = 'NoInvoice'): array
{
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('renew'), [
'order-id' => $orderId,
'months' => $months,
'invoice-option' => $invoiceOption,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Renewal failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: renewOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Renewal failed due to a network error.'];
}
}
/**
* @return array{success: bool, message?: string}
*/
public function changePanelPassword(string $orderId, string $password): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('change-password'), [
'order-id' => $orderId,
'new-passwd' => $password,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? $data['error'] ?? 'Could not update the hosting panel password.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: changePanelPassword failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not update the hosting panel password.'];
}
}
/**
* Delete (cancel) a hosting order.
*
* @return array{success: bool, message?: string}
*/
public function deleteOrder(string $orderId): array
{
try {
$response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('delete'), [
'order-id' => $orderId,
]));
$data = $response->json();
if ($response->failed()) {
return ['success' => false, 'message' => $data['message'] ?? 'Cancellation failed.'];
}
return ['success' => true];
} catch (\Throwable $e) {
Log::error('HostingService: deleteOrder failed', [
'order_id' => $orderId,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Cancellation failed due to a network error.'];
}
}
/**
* Sync a hosting order from ResellerClub into the local database.
*/
public function syncOrderToLocal(array $rcOrder, int $userId, ?int $domainId = null): HostingOrder
{
$raw = is_array($rcOrder['raw'] ?? null) ? (array) $rcOrder['raw'] : [];
return HostingOrder::query()->updateOrCreate(
['rc_order_id' => (string) ($rcOrder['orderid'] ?? $rcOrder['order_id'] ?? $rcOrder['entityid'] ?? Arr::get($raw, 'orders.orderid') ?? Arr::get($raw, 'entity.entityid') ?? $raw['orderid'] ?? $raw['entityid'] ?? '')],
[
'user_id' => $userId,
'domain_id' => $domainId,
'domain_name' => $rcOrder['domain_name'] ?? $rcOrder['domainname'] ?? Arr::get($raw, 'entity.description') ?? Arr::get($raw, 'domainname') ?? Arr::get($raw, 'domain-name') ?? '',
'rc_customer_id' => (string) ($rcOrder['customerid'] ?? $rcOrder['customer_id'] ?? $rcOrder['customer-id'] ?? Arr::get($raw, 'entity.customerid') ?? $raw['customerid'] ?? $raw['customer-id'] ?? ''),
'plan_name' => $rcOrder['plan_name'] ?? $rcOrder['planname'] ?? $rcOrder['productcategory'] ?? Arr::get($raw, 'entitytype.entitytypename') ?? Arr::get($raw, 'entitytype.entitytypekey') ?? $raw['planname'] ?? $raw['productcategory'] ?? null,
'hosting_type' => HostingOrder::TYPE_SINGLE_DOMAIN,
'status' => $this->mapRcStatus($rcOrder['currentstatus'] ?? $rcOrder['orderstatus'] ?? $rcOrder['status'] ?? Arr::get($raw, 'entity.currentstatus') ?? Arr::get($raw, 'orders.orderstatus') ?? 'pending'),
'ip_address' => $rcOrder['ipaddress'] ?? $rcOrder['ip'] ?? $rcOrder['ip_address'] ?? Arr::get($raw, 'server.ipaddress') ?? Arr::get($raw, 'server.ip') ?? $raw['ipaddress'] ?? $raw['ip'] ?? null,
'cpanel_url' => $rcOrder['cpanelurl'] ?? $rcOrder['cpanel_url'] ?? $rcOrder['tempurl'] ?? Arr::get($raw, 'server.cpanelurl') ?? Arr::get($raw, 'server.controlpanelurl') ?? Arr::get($raw, 'server.tempurl') ?? $raw['cpanelurl'] ?? $raw['tempurl'] ?? null,
'cpanel_username' => $rcOrder['cpanelusername'] ?? $rcOrder['cpanel_username'] ?? $rcOrder['siteadminusername'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? $raw['siteadminusername'] ?? null,
'bandwidth_mb' => $this->unsignedLimitOrNull($rcOrder['bandwidth'] ?? Arr::get($raw, 'plan.bandwidth') ?? $raw['bandwidth'] ?? null),
'disk_space_mb' => $this->unsignedLimitOrNull($rcOrder['diskspace'] ?? Arr::get($raw, 'plan.diskspace') ?? $raw['diskspace'] ?? null),
'expires_at' => $this->hostingTimestamp($rcOrder['endtime'] ?? $rcOrder['end_time'] ?? Arr::get($raw, 'orders.endtime') ?? $raw['endtime'] ?? null),
'provisioned_at' => $this->hostingTimestamp($rcOrder['creationtime'] ?? $rcOrder['creation_time'] ?? Arr::get($raw, 'orders.creationtime') ?? $raw['creationtime'] ?? null),
'meta' => $raw !== [] ? $raw : $rcOrder,
]
);
}
/**
* Sync all orders from ResellerClub for a user.
* If user has a linked RC account, only syncs that customer's orders.
* Otherwise syncs all orders under the reseller account.
*
* @return int Number of orders synced.
*/
public function syncAllOrders(int $userId, ?string $rcCustomerId = null): int
{
if ($rcCustomerId) {
return $this->syncCustomerOrders($rcCustomerId, $userId);
}
$page = 1;
$synced = 0;
do {
$result = $this->searchOrders($page, 50);
if (! ($result['success'] ?? false) || empty($result['orders'])) {
break;
}
foreach ($result['orders'] as $order) {
$this->syncOrderToLocal($order, $userId);
$synced++;
}
$page++;
} while ($synced < ($result['total'] ?? 0));
return $synced;
}
private function normalizeOrderData(array $data): array
{
return [
'order_id' => (string) ($data['orderid'] ?? $data['entityid'] ?? $data['order-id'] ?? $data['entity-id'] ?? Arr::get($data, 'orders.orderid') ?? Arr::get($data, 'entity.entityid') ?? ''),
'domain_name' => $data['domainname'] ?? $data['domain-name'] ?? $data['domain_name'] ?? $data['domain'] ?? $data['hostname'] ?? $data['description'] ?? Arr::get($data, 'entity.description') ?? '',
'customer_id' => (string) ($data['customerid'] ?? $data['customer-id'] ?? $data['customer_id'] ?? Arr::get($data, 'entity.customerid') ?? ''),
'plan_name' => $data['planname'] ?? $data['plan_name'] ?? $data['productcategory'] ?? $data['productkey'] ?? $data['description'] ?? Arr::get($data, 'entitytype.entitytypename') ?? Arr::get($data, 'entitytype.entitytypekey') ?? '',
'status' => $data['currentstatus'] ?? $data['orderstatus'] ?? $data['status'] ?? Arr::get($data, 'entity.currentstatus') ?? Arr::get($data, 'orders.orderstatus') ?? 'Unknown',
'ip_address' => $data['ipaddress'] ?? $data['ip_address'] ?? $data['ip'] ?? Arr::get($data, 'server.ipaddress') ?? Arr::get($data, 'server.ip') ?? null,
'cpanel_url' => $data['cpanelurl'] ?? $data['cpanel_url'] ?? $data['controlpanelurl'] ?? $data['tempurl'] ?? Arr::get($data, 'server.cpanelurl') ?? Arr::get($data, 'server.controlpanelurl') ?? Arr::get($data, 'server.tempurl') ?? null,
'cpanel_username' => $data['cpanelusername'] ?? $data['cpanel_username'] ?? $data['siteadminusername'] ?? $data['username'] ?? Arr::get($data, 'server.cpanelusername') ?? Arr::get($data, 'server.username') ?? null,
'bandwidth' => $data['bandwidth'] ?? $data['bandwidth_mb'] ?? Arr::get($data, 'plan.bandwidth') ?? null,
'disk_space' => $data['diskspace'] ?? $data['disk_space_mb'] ?? Arr::get($data, 'plan.diskspace') ?? null,
'creation_time' => $data['creationtime'] ?? $data['creation_time'] ?? Arr::get($data, 'orders.creationtime') ?? null,
'end_time' => $data['endtime'] ?? $data['end_time'] ?? $data['expirytime'] ?? Arr::get($data, 'orders.endtime') ?? null,
'raw' => $data,
];
}
private function mapRcStatus(string $rcStatus): string
{
return match (strtolower($rcStatus)) {
'active', 'active (paid)' => HostingOrder::STATUS_ACTIVE,
'suspended' => HostingOrder::STATUS_SUSPENDED,
'deleted', 'cancelled' => HostingOrder::STATUS_CANCELLED,
'expired' => HostingOrder::STATUS_EXPIRED,
'pending', 'inactive' => HostingOrder::STATUS_PENDING,
default => HostingOrder::STATUS_PENDING,
};
}
private function syncErrorMessage(\Throwable $e, string $fallback): string
{
$message = $e->getMessage();
if (str_contains($message, 'Could not resolve host')) {
return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.';
}
return $fallback;
}
/**
* @param mixed $data
*/
private function responseMessage(Response $response, mixed $data, string $fallback): string
{
if ($this->looksLikeHtmlError($response)) {
return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.';
}
if (is_array($data)) {
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeErrorText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
}
$rawBody = $this->sanitizeErrorText((string) $response->body());
return $rawBody !== '' ? $rawBody : $fallback;
}
private function sanitizeErrorText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.';
}
return trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
}
private function looksLikeHtmlError(Response $response): bool
{
$contentType = strtolower((string) $response->header('Content-Type'));
$body = trim((string) $response->body());
return str_contains($contentType, 'text/html') || $this->looksLikeHtmlDocument($body);
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| str_contains($normalized, 'cloudflare');
}
private function hostingTimestamp(mixed $value): ?\Carbon\Carbon
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
return \Carbon\Carbon::createFromTimestamp((int) $value);
}
try {
return \Carbon\Carbon::parse((string) $value);
} catch (\Throwable) {
return null;
}
}
private function unsignedLimitOrNull(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$normalized = (int) $value;
return $normalized >= 0 ? $normalized : null;
}
}