Files
ladill-hosting/app/Services/Hosting/ResellerClubHostingService.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
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>
2026-06-06 16:24:20 +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'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? 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['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;
}
}