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>
2021 lines
68 KiB
PHP
2021 lines
68 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Domain;
|
|
|
|
use App\Models\User;
|
|
use App\Support\DomainConfig;
|
|
use Illuminate\Http\Client\PendingRequest;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Str;
|
|
|
|
class DomainRegistrarService
|
|
{
|
|
private const DEFAULT_API_URL = 'https://httpapi.com/api';
|
|
private const DEFAULT_DOMAINCHECK_API_URL = 'https://domaincheck.httpapi.com/api';
|
|
|
|
private const CUSTOMER_PRICE_TABLE_CACHE_KEY = 'rc:domain-customer-price-table:v1';
|
|
private const PRODUCT_DETAILS_CACHE_KEY = 'rc:domain-product-details:v1';
|
|
private const CUSTOMER_PRICE_TABLE_TTL_SECONDS = 21600;
|
|
private const ORDER_ID_CACHE_TTL_SECONDS = 900;
|
|
private const API_BLOCK_CACHE_KEY = 'rc:api-security-blocked';
|
|
private const API_BLOCK_TTL_SECONDS = 600;
|
|
private const PRODUCT_KEY_CACHE_MISS = '__missing__';
|
|
|
|
private string $apiUrl;
|
|
|
|
private string $domainCheckApiUrl;
|
|
|
|
private string $authUserId;
|
|
|
|
private string $apiKey;
|
|
|
|
private string $customerId;
|
|
|
|
private string $defaultContactId;
|
|
|
|
private string $sellingCurrency;
|
|
|
|
/**
|
|
* @var array<string, string>
|
|
*/
|
|
private array $tldProductKeys;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
|
|
$this->domainCheckApiUrl = rtrim($this->configuredDomainCheckApiUrl(), '/');
|
|
$this->authUserId = trim((string) config('mailinfra.registrar_auth_userid'));
|
|
$this->apiKey = trim((string) config('mailinfra.registrar_api_key'));
|
|
$this->customerId = trim((string) config('mailinfra.registrar_customer_id'));
|
|
$this->defaultContactId = trim((string) config('mailinfra.registrar_default_contact_id'));
|
|
$this->sellingCurrency = strtoupper(trim((string) config('mailinfra.registrar_selling_currency', 'GHS')));
|
|
$this->tldProductKeys = DomainConfig::resellerClubTldProductKeys();
|
|
}
|
|
|
|
public function isConfigured(): bool
|
|
{
|
|
return $this->authUserId !== '' && $this->apiKey !== '';
|
|
}
|
|
|
|
/**
|
|
* Resolve (find or create) a ResellerClub contact for the given user and customer.
|
|
*
|
|
* @return array{success: bool, contact_id?: string, message?: string}
|
|
*/
|
|
public function resolveContactForCustomer(User $user, string $customerId): array
|
|
{
|
|
if ($user->rc_contact_id) {
|
|
return ['success' => true, 'contact_id' => (string) $user->rc_contact_id];
|
|
}
|
|
|
|
$defaults = (array) config('mailinfra.resellerclub_customer_defaults', []);
|
|
$email = strtolower(trim((string) $user->email));
|
|
$name = trim(implode(' ', array_filter([
|
|
trim((string) ($user->first_name ?? '')),
|
|
trim((string) ($user->last_name ?? '')),
|
|
])));
|
|
|
|
if ($name === '') {
|
|
$name = trim((string) $user->name) !== '' ? (string) $user->name : $email;
|
|
}
|
|
|
|
$found = $this->searchContact($customerId, $email);
|
|
if ($found !== null) {
|
|
$user->forceFill(['rc_contact_id' => $found])->save();
|
|
|
|
return ['success' => true, 'contact_id' => $found];
|
|
}
|
|
|
|
try {
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/contacts/add.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'name' => $name,
|
|
'company' => trim((string) ($user->company ?? '')) !== ''
|
|
? (string) $user->company
|
|
: (string) ($defaults['company'] ?? config('app.name', 'Ladill')),
|
|
'email' => $email,
|
|
'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== ''
|
|
? (string) $user->address_line_1
|
|
: (string) ($defaults['address_line_1'] ?? 'Not provided'),
|
|
'city' => trim((string) ($user->city ?? '')) !== ''
|
|
? (string) $user->city
|
|
: (string) ($defaults['city'] ?? 'Accra'),
|
|
'state' => trim((string) ($user->state ?? '')) !== ''
|
|
? (string) $user->state
|
|
: (string) ($defaults['state'] ?? 'Not Applicable'),
|
|
'country' => trim((string) ($user->country ?? '')) !== ''
|
|
? strtoupper((string) $user->country)
|
|
: (string) ($defaults['country'] ?? 'GH'),
|
|
'zipcode' => trim((string) ($user->zipcode ?? '')) !== ''
|
|
? (string) $user->zipcode
|
|
: (string) ($defaults['zipcode'] ?? '00000'),
|
|
'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== ''
|
|
? ltrim((string) $user->phone_cc, '+')
|
|
: (string) ($defaults['phone_cc'] ?? '233'),
|
|
'phone' => trim((string) ($user->phone ?? '')) !== ''
|
|
? (string) $user->phone
|
|
: (string) ($defaults['phone'] ?? '000000000'),
|
|
'customer-id' => $customerId,
|
|
'type' => 'Contact',
|
|
])
|
|
);
|
|
|
|
$body = trim((string) $response->body());
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('DomainRegistrarService: contact creation failed', [
|
|
'user_id' => $user->id,
|
|
'customer_id' => $customerId,
|
|
'status' => $response->status(),
|
|
'body' => $body,
|
|
]);
|
|
|
|
return ['success' => false, 'message' => $this->extractResponseMessage($response, 'Could not create the domain contact.')];
|
|
}
|
|
|
|
$contactId = trim($body, "\" \t\n\r");
|
|
|
|
if ($contactId === '' || ! is_numeric($contactId)) {
|
|
Log::warning('DomainRegistrarService: unexpected contact creation response', [
|
|
'user_id' => $user->id,
|
|
'body' => $body,
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Could not create the domain contact.'];
|
|
}
|
|
|
|
$user->forceFill(['rc_contact_id' => $contactId])->save();
|
|
|
|
return ['success' => true, 'contact_id' => $contactId];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: resolveContactForCustomer failed', [
|
|
'user_id' => $user->id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Could not create the domain contact.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search for an existing contact under a customer by email.
|
|
*/
|
|
private function searchContact(string $customerId, string $email): ?string
|
|
{
|
|
try {
|
|
$response = $this->rcReadClient()->get($this->apiUrl.'/contacts/search.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'customer-id' => $customerId,
|
|
'no-of-records' => 1,
|
|
'page-no' => 1,
|
|
'type' => 'Contact',
|
|
'email' => $email,
|
|
]);
|
|
|
|
$data = $response->json();
|
|
|
|
if (! $response->successful() || ! is_array($data)) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($data as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$contactId = (string) ($row['entity.entityid'] ?? '');
|
|
if ($contactId !== '') {
|
|
return $contactId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (\Throwable $e) {
|
|
Log::warning('DomainRegistrarService: searchContact failed', [
|
|
'customer_id' => $customerId,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $defaultTlds
|
|
* @return array{
|
|
* success: bool,
|
|
* results?: array<int, array{
|
|
* domain: string,
|
|
* available: bool,
|
|
* transferable: bool,
|
|
* status: string,
|
|
* status_label: string,
|
|
* premium: bool,
|
|
* price: string|null,
|
|
* transfer_price: string|null,
|
|
* currency: string|null,
|
|
* price_minor: int|null,
|
|
* transfer_price_minor: int|null
|
|
* }>,
|
|
* exact?: bool,
|
|
* keyword?: string,
|
|
* tlds?: array<int, string>,
|
|
* message?: string
|
|
* }
|
|
*/
|
|
public function checkAvailability(string $query, array $defaultTlds): array
|
|
{
|
|
$parsed = $this->parseSearchQuery($query, $defaultTlds);
|
|
|
|
if (! $parsed['valid']) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $parsed['message'],
|
|
];
|
|
}
|
|
|
|
try {
|
|
$response = $this->rcReadClient()->get(
|
|
$this->buildRepeatedQueryUrl(
|
|
$this->domainCheckApiUrl.'/domains/available.json',
|
|
[
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => [$parsed['keyword']],
|
|
'tlds' => $parsed['tlds'],
|
|
]
|
|
)
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('DomainRegistrarService: availability request failed', [
|
|
'query' => $query,
|
|
'status' => $response->status(),
|
|
'response' => Str::limit($response->body(), 500),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Could not check domain availability right now.'),
|
|
];
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
if (! is_array($data)) {
|
|
Log::warning('DomainRegistrarService: unexpected availability response', [
|
|
'query' => $query,
|
|
'response' => Str::limit($response->body(), 500),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'The registrar returned an unexpected response. Please try again in a moment.',
|
|
];
|
|
}
|
|
|
|
$pricingMap = $this->preloadPricingForTlds((array) $parsed['tlds']);
|
|
|
|
return [
|
|
'success' => true,
|
|
'exact' => $parsed['exact'],
|
|
'keyword' => $parsed['keyword'],
|
|
'tlds' => $parsed['tlds'],
|
|
'results' => $this->mapAvailabilityResults($parsed['keyword'], $parsed['tlds'], $data, $pricingMap),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: availability check failed', [
|
|
'query' => $query,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Could not reach the domain registry right now. Please try again in a moment.',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $tlds
|
|
* @return array{
|
|
* success: bool,
|
|
* results?: array<int, array{
|
|
* domain: string,
|
|
* available: bool,
|
|
* transferable: bool,
|
|
* status: string,
|
|
* status_label: string,
|
|
* premium: bool,
|
|
* price: string|null,
|
|
* transfer_price: string|null,
|
|
* currency: string|null,
|
|
* price_minor: int|null,
|
|
* transfer_price_minor: int|null
|
|
* }>,
|
|
* message?: string
|
|
* }
|
|
*/
|
|
public function suggestDomains(string $keyword, array $tlds = [], ?string $excludeDomain = null): array
|
|
{
|
|
$keyword = $this->normalizeSearchQuery($keyword);
|
|
|
|
if ($keyword === '' || str_contains($keyword, '.')) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Enter a base name such as "ladill" to get suggestions.',
|
|
];
|
|
}
|
|
|
|
try {
|
|
$payload = [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'keyword' => $keyword,
|
|
'exact-match' => false,
|
|
'adult' => false,
|
|
];
|
|
|
|
if ($tlds !== []) {
|
|
$payload['tld-only'] = array_values(array_unique(array_filter($tlds)));
|
|
}
|
|
|
|
$response = $this->rcReadClient()->get(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/v5/suggest-names.json', $payload)
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Could not load domain suggestions right now.'),
|
|
];
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
if (! is_array($data)) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'The registrar returned an unexpected suggestion response.',
|
|
];
|
|
}
|
|
|
|
$normalizedTlds = array_values(array_unique(array_map(
|
|
fn ($tld) => ltrim(strtolower((string) $tld), '.'),
|
|
array_filter($tlds)
|
|
)));
|
|
|
|
$pricingMap = $normalizedTlds !== [] ? $this->preloadPricingForTlds($normalizedTlds) : [];
|
|
|
|
return [
|
|
'success' => true,
|
|
'results' => $this->mapSuggestedDomains($data, $excludeDomain, $pricingMap),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: suggestion lookup failed', [
|
|
'keyword' => $keyword,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Could not load domain suggestions right now.',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, domain?: string, transferable?: bool, message?: string}
|
|
*/
|
|
public function validateTransfer(string $domain): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain);
|
|
|
|
if ($domain === null) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Enter a valid domain name to transfer.',
|
|
];
|
|
}
|
|
|
|
try {
|
|
$response = $this->rcReadClient()->get($this->apiUrl.'/domains/validate-transfer.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => $domain,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Could not validate this transfer right now.'),
|
|
];
|
|
}
|
|
|
|
$transferable = $this->booleanFromResponse($response);
|
|
|
|
return [
|
|
'success' => true,
|
|
'domain' => $domain,
|
|
'transferable' => $transferable,
|
|
'message' => $transferable
|
|
? 'This domain can be transferred.'
|
|
: 'This domain cannot be transferred right now. Check the auth code, lock status, and recent registration changes.',
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: transfer validation failed', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Could not validate this transfer right now.',
|
|
];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a domain via the registrar API.
|
|
*
|
|
* @return array{success: bool, message?: string, order_id?: string}
|
|
*/
|
|
public function registerDomain(string $domain, int $years = 1, ?string $customerId = null, ?string $contactId = null): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain) ?? $domain;
|
|
$contact = $contactId ?: $this->defaultContactId;
|
|
|
|
try {
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/register.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => $domain,
|
|
'years' => $years,
|
|
'ns' => (array) config('mailinfra.nameservers'),
|
|
'customer-id' => $customerId ?: $this->customerId,
|
|
'reg-contact-id' => $contact,
|
|
'admin-contact-id' => $contact,
|
|
'tech-contact-id' => $contact,
|
|
'billing-contact-id' => $contact,
|
|
'invoice-option' => 'NoInvoice',
|
|
'protect-privacy' => true,
|
|
'auto-renew' => true,
|
|
])
|
|
);
|
|
|
|
$data = $response->json();
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('DomainRegistrarService: registration failed', [
|
|
'domain' => $domain,
|
|
'status' => $response->status(),
|
|
'response' => $data,
|
|
]);
|
|
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Registration failed.'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'order_id' => (string) ($data['entityid'] ?? ''),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: registration exception', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Registration failed due to a network error.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string, order_id?: string}
|
|
*/
|
|
public function transferDomain(string $domain, ?string $authCode = null, ?string $customerId = null, ?string $contactId = null): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain);
|
|
|
|
if ($domain === null) {
|
|
return ['success' => false, 'message' => 'Enter a valid domain name to transfer.'];
|
|
}
|
|
|
|
[$adminContactId, $techContactId, $billingContactId] = $this->transferContactProfileForDomain(
|
|
$domain,
|
|
$contactId ?: $this->defaultContactId
|
|
);
|
|
|
|
$params = [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => $domain,
|
|
'ns' => (array) config('mailinfra.nameservers'),
|
|
'customer-id' => $customerId ?: $this->customerId,
|
|
'reg-contact-id' => $contactId ?: $this->defaultContactId,
|
|
'admin-contact-id' => $adminContactId,
|
|
'tech-contact-id' => $techContactId,
|
|
'billing-contact-id' => $billingContactId,
|
|
'invoice-option' => 'NoInvoice',
|
|
'purchase-privacy' => true,
|
|
'protect-privacy' => true,
|
|
'auto-renew' => true,
|
|
];
|
|
|
|
if ($authCode !== null && trim($authCode) !== '') {
|
|
$params['auth-code'] = trim($authCode);
|
|
}
|
|
|
|
try {
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/transfer.json', $params)
|
|
);
|
|
|
|
$data = $response->json();
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Transfer could not be started right now.'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'order_id' => (string) ($data['entityid'] ?? ''),
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: transfer exception', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Transfer could not be started due to a network error.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
public function setNameservers(string $domain, array $nameservers): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain);
|
|
$nameservers = collect($nameservers)
|
|
->map(fn ($value) => strtolower(trim((string) $value, " \t\n\r\0\x0B.")))
|
|
->filter()
|
|
->unique()
|
|
->values()
|
|
->all();
|
|
|
|
if ($domain === null) {
|
|
return ['success' => false, 'message' => 'Enter a valid domain name.'];
|
|
}
|
|
|
|
if (count($nameservers) < 2) {
|
|
return ['success' => false, 'message' => 'Enter at least two nameservers.'];
|
|
}
|
|
|
|
try {
|
|
$orderId = $this->getOrderId($domain);
|
|
|
|
if ($orderId === '') {
|
|
return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.'];
|
|
}
|
|
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/modify-ns.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'order-id' => $orderId,
|
|
'ns' => $nameservers,
|
|
])
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Could not update nameservers right now.'),
|
|
];
|
|
}
|
|
|
|
return ['success' => true];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: setNameservers failed', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Could not update nameservers right now.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{
|
|
* success: bool,
|
|
* message?: string,
|
|
* order_id?: string,
|
|
* auth_code?: string|null,
|
|
* auth_code_supported?: bool,
|
|
* privacy_supported?: bool,
|
|
* privacy_enabled?: bool|null,
|
|
* transfer_lock_supported?: bool,
|
|
* transfer_lock_enabled?: bool|null,
|
|
* nameservers?: array<int, string>,
|
|
* status?: string|null
|
|
* }
|
|
*/
|
|
public function getRegistrarControlsSnapshot(string $domain): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain);
|
|
|
|
if ($domain === null) {
|
|
return ['success' => false, 'message' => 'Enter a valid domain name.'];
|
|
}
|
|
|
|
$orderId = $this->getOrderId($domain);
|
|
if ($orderId === '') {
|
|
return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.'];
|
|
}
|
|
|
|
try {
|
|
$detailsResponse = $this->rcReadClient()->get(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/details-by-name.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => $domain,
|
|
'options' => ['OrderDetails', 'NsDetails', 'DomainStatus'],
|
|
])
|
|
);
|
|
|
|
if ($detailsResponse->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($detailsResponse, 'Could not load registrar controls right now.'),
|
|
];
|
|
}
|
|
|
|
$details = $detailsResponse->json();
|
|
if (! is_array($details)) {
|
|
$details = [];
|
|
}
|
|
|
|
$locksResponse = $this->rcReadClient()->get($this->apiUrl.'/domains/locks.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'order-id' => $orderId,
|
|
]);
|
|
|
|
$locks = [];
|
|
if ($locksResponse->successful() && is_array($locksResponse->json())) {
|
|
$locks = $locksResponse->json();
|
|
}
|
|
|
|
$tld = $this->extractTld($domain);
|
|
$rawAuthCode = $this->extractSnapshotValue($details, ['domsecret', 'authcode', 'auth-code', 'eppcode', 'epp-code']);
|
|
$rawPrivacy = $this->extractSnapshotValue($details, ['isprivacyprotected', 'privacyprotected', 'privacy-protected', 'privacy']);
|
|
$rawStatus = $this->extractSnapshotValue($details, ['currentstatus', 'status', 'orderstatus']);
|
|
$rawNameservers = data_get($details, 'ns');
|
|
|
|
return [
|
|
'success' => true,
|
|
'order_id' => $orderId,
|
|
'auth_code' => is_scalar($rawAuthCode) ? (string) $rawAuthCode : null,
|
|
'auth_code_supported' => $this->authCodeSupportedForTld($tld),
|
|
'privacy_supported' => $this->privacySupportedForTld($tld),
|
|
'privacy_enabled' => $this->toNullableBool($rawPrivacy),
|
|
'transfer_lock_supported' => $this->transferLockSupportedForTld($tld),
|
|
'transfer_lock_enabled' => $this->toNullableBool(data_get($locks, 'transferlock')),
|
|
'nameservers' => $this->normalizeNameserverList($rawNameservers),
|
|
'status' => is_scalar($rawStatus) ? (string) $rawStatus : null,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: getRegistrarControlsSnapshot failed', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Could not load registrar controls right now.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
public function updateAuthCode(string $domain, string $authCode): array
|
|
{
|
|
$authCode = trim($authCode);
|
|
|
|
if ($authCode === '') {
|
|
return ['success' => false, 'message' => 'Enter a new auth/EPP code.'];
|
|
}
|
|
|
|
return $this->performDomainOrderWrite(
|
|
$domain,
|
|
'/domains/modify-auth-code.json',
|
|
['auth-code' => $authCode],
|
|
'Could not update the auth/EPP code right now.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
public function setTransferLock(string $domain, bool $enabled): array
|
|
{
|
|
return $this->performDomainOrderWrite(
|
|
$domain,
|
|
$enabled ? '/domains/enable-theft-protection.json' : '/domains/disable-theft-protection.json',
|
|
[],
|
|
$enabled
|
|
? 'Could not enable registrar lock right now.'
|
|
: 'Could not disable registrar lock right now.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
public function setPrivacyProtection(string $domain, bool $enabled, string $reason = 'Managed from Ladill dashboard.'): array
|
|
{
|
|
return $this->performDomainOrderWrite(
|
|
$domain,
|
|
'/domains/modify-privacy-protection.json',
|
|
[
|
|
'protect-privacy' => $enabled,
|
|
'reason' => trim($reason) !== '' ? trim($reason) : 'Managed from Ladill dashboard.',
|
|
],
|
|
$enabled
|
|
? 'Could not enable privacy protection right now.'
|
|
: 'Could not disable privacy protection right now.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string}
|
|
*/
|
|
public function quoteDomainRegistration(string $domain): array
|
|
{
|
|
$results = $this->checkAvailability($domain, DomainConfig::searchTlds());
|
|
|
|
if (! ($results['success'] ?? false)) {
|
|
return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain right now.'];
|
|
}
|
|
|
|
$result = collect((array) ($results['results'] ?? []))->first();
|
|
|
|
if (! is_array($result) || ! ($result['available'] ?? false)) {
|
|
return ['success' => false, 'message' => 'This domain is no longer available for registration.'];
|
|
}
|
|
|
|
$pricing = $this->preferredDomainPricing(
|
|
(string) ($result['domain'] ?? $domain),
|
|
'addnewdomain',
|
|
[
|
|
'amount_minor' => (int) ($result['price_minor'] ?? 0),
|
|
'currency' => (string) ($result['currency'] ?? ''),
|
|
'label' => (string) ($result['price'] ?? ''),
|
|
]
|
|
);
|
|
|
|
$amountMinor = (int) ($pricing['amount_minor'] ?? 0);
|
|
|
|
if ($amountMinor <= 0) {
|
|
return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'registration')];
|
|
}
|
|
|
|
$currency = (string) ($pricing['currency'] ?? $this->sellingCurrency);
|
|
|
|
return [
|
|
'success' => true,
|
|
'domain' => (string) ($result['domain'] ?? $domain),
|
|
'amount_minor' => $amountMinor,
|
|
'currency' => $currency,
|
|
'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string}
|
|
*/
|
|
public function quoteDomainTransfer(string $domain): array
|
|
{
|
|
$validation = $this->validateTransfer($domain);
|
|
|
|
if (! ($validation['success'] ?? false) || ! ($validation['transferable'] ?? false)) {
|
|
return ['success' => false, 'message' => $validation['message'] ?? 'This domain is not ready for transfer.'];
|
|
}
|
|
|
|
$results = $this->checkAvailability($domain, DomainConfig::searchTlds());
|
|
|
|
if (! ($results['success'] ?? false)) {
|
|
return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain transfer right now.'];
|
|
}
|
|
|
|
$result = collect((array) ($results['results'] ?? []))->first();
|
|
|
|
if (! is_array($result) || ! ($result['transferable'] ?? false)) {
|
|
return ['success' => false, 'message' => 'This domain is not ready for transfer.'];
|
|
}
|
|
|
|
$pricing = $this->preferredDomainPricing(
|
|
(string) ($result['domain'] ?? $domain),
|
|
'addtransferdomain',
|
|
[
|
|
'amount_minor' => (int) ($result['transfer_price_minor'] ?? 0),
|
|
'currency' => (string) ($result['currency'] ?? ''),
|
|
'label' => (string) ($result['transfer_price'] ?? ''),
|
|
]
|
|
);
|
|
|
|
$amountMinor = (int) ($pricing['amount_minor'] ?? 0);
|
|
|
|
if ($amountMinor <= 0) {
|
|
return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'transfer')];
|
|
}
|
|
|
|
$currency = (string) ($pricing['currency'] ?? $this->sellingCurrency);
|
|
|
|
return [
|
|
'success' => true,
|
|
'domain' => (string) ($result['domain'] ?? $domain),
|
|
'amount_minor' => $amountMinor,
|
|
'currency' => $currency,
|
|
'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, rc_order_id?: string, message?: string}
|
|
*/
|
|
public function quoteDomainRenewal(string $domain, int $years = 1): array
|
|
{
|
|
$domain = strtolower(trim($domain));
|
|
$years = max(1, min(10, $years));
|
|
$rcOrderId = $this->getOrderId($domain);
|
|
|
|
if ($rcOrderId === '') {
|
|
return ['success' => false, 'message' => 'This domain is not registered with ResellerClub in our system.'];
|
|
}
|
|
|
|
$pricing = $this->preferredDomainPricing($domain, 'renewdomain', []);
|
|
|
|
$amountMinor = (int) ($pricing['amount_minor'] ?? 0) * $years;
|
|
|
|
if ($amountMinor <= 0) {
|
|
return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'renewal')];
|
|
}
|
|
|
|
$currency = (string) ($pricing['currency'] ?? $this->sellingCurrency);
|
|
|
|
return [
|
|
'success' => true,
|
|
'domain' => $domain,
|
|
'rc_order_id' => $rcOrderId,
|
|
'amount_minor' => $amountMinor,
|
|
'currency' => $currency,
|
|
'price_label' => $this->formatMinorLabel($amountMinor, $currency),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{success: bool, order_id?: string, message?: string}
|
|
*/
|
|
public function renewDomain(string $domain, int $years = 1, ?string $customerId = null): array
|
|
{
|
|
$domain = strtolower(trim($domain));
|
|
$years = max(1, min(10, $years));
|
|
$rcOrderId = $this->getOrderId($domain);
|
|
|
|
if ($rcOrderId === '') {
|
|
return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.'];
|
|
}
|
|
|
|
try {
|
|
$params = [
|
|
'years' => $years,
|
|
'invoice-option' => 'NoInvoice',
|
|
];
|
|
|
|
if ($customerId !== null && $customerId !== '') {
|
|
$params['customer-id'] = $customerId;
|
|
}
|
|
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.'/domains/renew.json', array_merge([
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'order-id' => $rcOrderId,
|
|
], $params))
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, 'Domain renewal failed.'),
|
|
];
|
|
}
|
|
|
|
return [
|
|
'success' => true,
|
|
'order_id' => $rcOrderId,
|
|
];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: renewDomain failed', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => 'Domain renewal failed due to a network error.'];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the order ID for a domain from the registrar.
|
|
*/
|
|
private function getOrderId(string $domain): string
|
|
{
|
|
$domain = strtolower(trim($domain));
|
|
|
|
if ($domain === '') {
|
|
return '';
|
|
}
|
|
|
|
return Cache::remember('rc:order-id:'.$domain, self::ORDER_ID_CACHE_TTL_SECONDS, function () use ($domain) {
|
|
try {
|
|
$response = $this->rcReadClient()->get($this->apiUrl.'/domains/orderid.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'domain-name' => $domain,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
return '';
|
|
}
|
|
|
|
$json = $response->json();
|
|
|
|
if (is_int($json) || is_float($json) || (is_string($json) && is_numeric($json))) {
|
|
return (string) $json;
|
|
}
|
|
|
|
$body = trim(strip_tags((string) $response->body()));
|
|
|
|
return is_numeric($body) ? $body : '';
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: getOrderId failed', [
|
|
'domain' => $domain,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return '';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $parameters
|
|
* @return array{success: bool, message?: string}
|
|
*/
|
|
private function performDomainOrderWrite(string $domain, string $path, array $parameters, string $fallback): array
|
|
{
|
|
$domain = $this->normalizeFullDomain($domain);
|
|
|
|
if ($domain === null) {
|
|
return ['success' => false, 'message' => 'Enter a valid domain name.'];
|
|
}
|
|
|
|
try {
|
|
$orderId = $this->getOrderId($domain);
|
|
if ($orderId === '') {
|
|
return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.'];
|
|
}
|
|
|
|
$response = $this->rcWriteClient()->post(
|
|
$this->buildRepeatedQueryUrl($this->apiUrl.$path, array_merge([
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
'order-id' => $orderId,
|
|
], $parameters))
|
|
);
|
|
|
|
if ($response->failed()) {
|
|
return [
|
|
'success' => false,
|
|
'message' => $this->extractResponseMessage($response, $fallback),
|
|
];
|
|
}
|
|
|
|
return ['success' => true];
|
|
} catch (\Throwable $e) {
|
|
Log::error('DomainRegistrarService: performDomainOrderWrite failed', [
|
|
'domain' => $domain,
|
|
'path' => $path,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return ['success' => false, 'message' => $fallback];
|
|
}
|
|
}
|
|
|
|
private function configuredApiUrl(): string
|
|
{
|
|
$value = trim((string) config('mailinfra.registrar_api_url'));
|
|
|
|
return $value !== '' ? $value : self::DEFAULT_API_URL;
|
|
}
|
|
|
|
private function configuredDomainCheckApiUrl(): string
|
|
{
|
|
$value = trim((string) config('mailinfra.registrar_domaincheck_api_url'));
|
|
|
|
return $value !== '' ? $value : self::DEFAULT_DOMAINCHECK_API_URL;
|
|
}
|
|
|
|
private function rcReadClient(): PendingRequest
|
|
{
|
|
return Http::acceptJson()
|
|
->connectTimeout(3)
|
|
->timeout(8)
|
|
->retry(2, 250, throw: false);
|
|
}
|
|
|
|
private function rcWriteClient(): PendingRequest
|
|
{
|
|
return Http::acceptJson()
|
|
->connectTimeout(5)
|
|
->timeout(25)
|
|
->retry(1, 300, throw: false);
|
|
}
|
|
|
|
/**
|
|
* RC expects repeated keys for array values instead of PHP-style key[0]=value encoding.
|
|
*
|
|
* @param array<string, mixed> $parameters
|
|
*/
|
|
private function buildRepeatedQueryUrl(string $baseUrl, array $parameters): string
|
|
{
|
|
$segments = [];
|
|
|
|
foreach ($parameters as $key => $value) {
|
|
if (is_array($value)) {
|
|
foreach ($value as $item) {
|
|
if ($item === null || $item === '') {
|
|
continue;
|
|
}
|
|
|
|
$segments[] = rawurlencode((string) $key).'='.rawurlencode((string) $item);
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($value === null || $value === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value;
|
|
$segments[] = rawurlencode((string) $key).'='.rawurlencode($normalized);
|
|
}
|
|
|
|
if ($segments === []) {
|
|
return $baseUrl;
|
|
}
|
|
|
|
return $baseUrl.(Str::contains($baseUrl, '?') ? '&' : '?').implode('&', $segments);
|
|
}
|
|
|
|
private function normalizeSearchQuery(string $query): string
|
|
{
|
|
$query = strtolower(trim($query));
|
|
$query = preg_replace('#^https?://#', '', $query) ?? $query;
|
|
$query = preg_replace('#/.*$#', '', $query) ?? $query;
|
|
$query = preg_replace('/^www\./', '', $query) ?? $query;
|
|
|
|
return trim($query, " \t\n\r\0\x0B.");
|
|
}
|
|
|
|
private function normalizeFullDomain(string $domain): ?string
|
|
{
|
|
$domain = $this->normalizeSearchQuery($domain);
|
|
|
|
if (! str_contains($domain, '.')) {
|
|
return null;
|
|
}
|
|
|
|
return preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]{2,63})+$/', $domain) === 1
|
|
? $domain
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @param array<int, string> $keys
|
|
*/
|
|
private function extractSnapshotValue(array $payload, array $keys): mixed
|
|
{
|
|
foreach ($keys as $key) {
|
|
$value = data_get($payload, $key);
|
|
if ($value !== null && $value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function toNullableBool(mixed $value): ?bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_int($value) || is_float($value)) {
|
|
return (bool) $value;
|
|
}
|
|
|
|
if (is_string($value)) {
|
|
$value = strtolower(trim($value));
|
|
if (in_array($value, ['true', '1', 'yes', 'enabled', 'active'], true)) {
|
|
return true;
|
|
}
|
|
|
|
if (in_array($value, ['false', '0', 'no', 'disabled', 'inactive'], true)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeNameserverList(mixed $value): array
|
|
{
|
|
if (! is_array($value)) {
|
|
return [];
|
|
}
|
|
|
|
return collect($value)
|
|
->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B.")))
|
|
->filter()
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
private function authCodeSupportedForTld(string $tld): bool
|
|
{
|
|
return ! $this->tldMatchesAny($tld, ['es', 'ru', 'uk', 'web.in', 'at']);
|
|
}
|
|
|
|
private function transferLockSupportedForTld(string $tld): bool
|
|
{
|
|
return ! $this->tldMatchesAny($tld, ['au', 'de', 'es', 'eu', 'fr', 'nl', 'ru', 'uk', 'web.in']);
|
|
}
|
|
|
|
private function privacySupportedForTld(string $tld): bool
|
|
{
|
|
return ! $this->tldMatchesAny($tld, [
|
|
'asia',
|
|
'au',
|
|
'ca',
|
|
'cn',
|
|
'org.co',
|
|
'mil.co',
|
|
'gov.co',
|
|
'edu.co',
|
|
'de',
|
|
'es',
|
|
'eu',
|
|
'in',
|
|
'nl',
|
|
'nz',
|
|
'pro',
|
|
'ru',
|
|
'sx',
|
|
'tel',
|
|
'uk',
|
|
'us',
|
|
'mx',
|
|
'at',
|
|
'web.in',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $blockedTlds
|
|
*/
|
|
private function tldMatchesAny(string $tld, array $blockedTlds): bool
|
|
{
|
|
foreach ($blockedTlds as $blockedTld) {
|
|
$blockedTld = strtolower(trim($blockedTld));
|
|
|
|
if ($blockedTld === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($tld === $blockedTld || str_ends_with($tld, '.'.$blockedTld)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $defaultTlds
|
|
* @return array{valid: bool, keyword?: string, tlds?: array<int, string>, exact?: bool, message?: string}
|
|
*/
|
|
private function parseSearchQuery(string $query, array $defaultTlds): array
|
|
{
|
|
$query = $this->normalizeSearchQuery($query);
|
|
|
|
if ($query === '') {
|
|
return ['valid' => false, 'message' => 'Enter a domain name to search.'];
|
|
}
|
|
|
|
if (str_contains($query, '.')) {
|
|
$domain = $this->normalizeFullDomain($query);
|
|
|
|
if ($domain === null) {
|
|
return ['valid' => false, 'message' => 'Enter a valid domain such as ladill.com.'];
|
|
}
|
|
|
|
$parts = explode('.', $domain);
|
|
$keyword = array_shift($parts);
|
|
$tld = implode('.', $parts);
|
|
|
|
return [
|
|
'valid' => true,
|
|
'keyword' => $keyword,
|
|
'tlds' => [$tld],
|
|
'exact' => true,
|
|
];
|
|
}
|
|
|
|
if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $query) !== 1) {
|
|
return ['valid' => false, 'message' => 'Enter a valid name such as ladill or ladill.com.'];
|
|
}
|
|
|
|
return [
|
|
'valid' => true,
|
|
'keyword' => $query,
|
|
'tlds' => array_values(array_unique(array_map(
|
|
fn ($tld) => ltrim(strtolower((string) $tld), '.'),
|
|
array_filter($defaultTlds)
|
|
))),
|
|
'exact' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $tlds
|
|
* @param array<string, mixed> $data
|
|
* @param array<string, array{addnewdomain?: array, addtransferdomain?: array}> $pricingMap
|
|
* @return array<int, array{
|
|
* domain: string,
|
|
* available: bool,
|
|
* transferable: bool,
|
|
* status: string,
|
|
* status_label: string,
|
|
* premium: bool,
|
|
* price: string|null,
|
|
* transfer_price: string|null,
|
|
* currency: string|null,
|
|
* price_minor: int|null,
|
|
* transfer_price_minor: int|null
|
|
* }>
|
|
*/
|
|
private function mapAvailabilityResults(string $keyword, array $tlds, array $data, array $pricingMap): array
|
|
{
|
|
$results = [];
|
|
|
|
foreach ($tlds as $tld) {
|
|
$normalizedTld = ltrim(strtolower($tld), '.');
|
|
$domain = $keyword.'.'.$normalizedTld;
|
|
$payload = is_array($data[$domain] ?? null) ? $data[$domain] : [];
|
|
$status = strtolower((string) ($payload['status'] ?? 'unknown'));
|
|
|
|
$inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create');
|
|
$inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer');
|
|
|
|
$pricedCreateQuote = $status === 'available'
|
|
? $this->resolveAvailabilityPriceQuote(
|
|
$domain,
|
|
'addnewdomain',
|
|
$inlineCreateQuote,
|
|
$pricingMap[$normalizedTld]['addnewdomain'] ?? []
|
|
)
|
|
: [];
|
|
|
|
$pricedTransferQuote = $status === 'regthroughothers'
|
|
? $this->resolveAvailabilityPriceQuote(
|
|
$domain,
|
|
'addtransferdomain',
|
|
$inlineTransferQuote,
|
|
$pricingMap[$normalizedTld]['addtransferdomain'] ?? []
|
|
)
|
|
: [];
|
|
|
|
$results[] = [
|
|
'domain' => $domain,
|
|
'available' => $status === 'available',
|
|
'transferable' => $status === 'regthroughothers',
|
|
'status' => $status,
|
|
'status_label' => $this->availabilityStatusLabel($status),
|
|
'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null),
|
|
'price' => $pricedCreateQuote['label'] ?? null,
|
|
'transfer_price' => $pricedTransferQuote['label'] ?? null,
|
|
'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null,
|
|
'price_minor' => $pricedCreateQuote['amount_minor'] ?? null,
|
|
'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null,
|
|
];
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @param array<string, array{addnewdomain?: array, addtransferdomain?: array}> $pricingMap
|
|
* @return array<int, array{
|
|
* domain: string,
|
|
* available: bool,
|
|
* transferable: bool,
|
|
* status: string,
|
|
* status_label: string,
|
|
* premium: bool,
|
|
* price: string|null,
|
|
* transfer_price: string|null,
|
|
* currency: string|null,
|
|
* price_minor: int|null,
|
|
* transfer_price_minor: int|null
|
|
* }>
|
|
*/
|
|
private function mapSuggestedDomains(array $data, ?string $excludeDomain = null, array $pricingMap = []): array
|
|
{
|
|
$results = [];
|
|
$excludeDomain = $excludeDomain !== null ? strtolower(trim($excludeDomain)) : null;
|
|
|
|
foreach ($data as $domain => $payload) {
|
|
if (! is_string($domain) || ! is_array($payload)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedDomain = strtolower(trim($domain));
|
|
|
|
if ($normalizedDomain === '' || $normalizedDomain === $excludeDomain || isset($results[$normalizedDomain])) {
|
|
continue;
|
|
}
|
|
|
|
$status = strtolower((string) ($payload['status'] ?? 'unknown'));
|
|
$tld = $this->extractTld($normalizedDomain);
|
|
|
|
$inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create');
|
|
$inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer');
|
|
|
|
$pricedCreateQuote = $status === 'available'
|
|
? $this->resolveAvailabilityPriceQuote(
|
|
$normalizedDomain,
|
|
'addnewdomain',
|
|
$inlineCreateQuote,
|
|
$pricingMap[$tld]['addnewdomain'] ?? []
|
|
)
|
|
: [];
|
|
|
|
$pricedTransferQuote = $status === 'regthroughothers'
|
|
? $this->resolveAvailabilityPriceQuote(
|
|
$normalizedDomain,
|
|
'addtransferdomain',
|
|
$inlineTransferQuote,
|
|
$pricingMap[$tld]['addtransferdomain'] ?? []
|
|
)
|
|
: [];
|
|
|
|
$results[$normalizedDomain] = [
|
|
'domain' => $normalizedDomain,
|
|
'available' => $status === 'available',
|
|
'transferable' => $status === 'regthroughothers',
|
|
'status' => $status,
|
|
'status_label' => $this->availabilityStatusLabel($status),
|
|
'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null),
|
|
'price' => $pricedCreateQuote['label'] ?? null,
|
|
'transfer_price' => $pricedTransferQuote['label'] ?? null,
|
|
'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null,
|
|
'price_minor' => $pricedCreateQuote['amount_minor'] ?? null,
|
|
'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null,
|
|
];
|
|
}
|
|
|
|
return array_values($results);
|
|
}
|
|
|
|
private function availabilityStatusLabel(string $status): string
|
|
{
|
|
return match ($status) {
|
|
'available' => 'Available',
|
|
'regthroughothers' => 'Taken',
|
|
'regthroughus' => 'Already registered',
|
|
'unknown' => 'Check again shortly',
|
|
default => 'Unavailable',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
* @return array{label?: string, amount_minor?: int, currency?: string}
|
|
*/
|
|
private function extractAvailabilityCostHashQuote(array $payload, string $operation): array
|
|
{
|
|
$costHash = $payload['costhash'] ?? $payload['costHash'] ?? null;
|
|
|
|
if (! is_array($costHash)) {
|
|
return [];
|
|
}
|
|
|
|
$value = $costHash[$operation] ?? null;
|
|
|
|
if (! is_numeric($value)) {
|
|
return [];
|
|
}
|
|
|
|
$symbol = trim((string) ($costHash['currency-symbol'] ?? $costHash['currencySymbol'] ?? ''));
|
|
$currency = strtoupper(trim((string) ($costHash['currency-code'] ?? $costHash['currencyCode'] ?? '')));
|
|
|
|
if ($currency === '') {
|
|
$currency = $this->resolveCurrencyCode($symbol);
|
|
}
|
|
|
|
if ($currency === '') {
|
|
$currency = $this->sellingCurrency;
|
|
}
|
|
|
|
$amountMinor = (int) round(((float) $value) * 100);
|
|
|
|
return [
|
|
'amount_minor' => $amountMinor,
|
|
'currency' => $currency,
|
|
'label' => $this->formatMinorLabel($amountMinor, $currency),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Customer-facing RC domain pricing source: cached customer-price table.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function getCustomerPriceTable(): array
|
|
{
|
|
return Cache::remember(self::CUSTOMER_PRICE_TABLE_CACHE_KEY, self::CUSTOMER_PRICE_TABLE_TTL_SECONDS, function () {
|
|
if ((bool) Cache::get(self::API_BLOCK_CACHE_KEY, false)) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$response = $this->rcReadClient()->get($this->apiUrl.'/products/customer-price.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$blocked = $this->isRegistrarSecurityBlock($response);
|
|
|
|
Log::warning('DomainRegistrarService: customer-price failed', [
|
|
'status' => $response->status(),
|
|
'blocked' => $blocked,
|
|
'body' => Str::limit($response->body(), 500),
|
|
]);
|
|
|
|
if ($blocked) {
|
|
Cache::put(self::API_BLOCK_CACHE_KEY, true, self::API_BLOCK_TTL_SECONDS);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
return is_array($data) ? $data : [];
|
|
} catch (\Throwable $e) {
|
|
Log::warning('DomainRegistrarService: customer-price exception', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function getProductDetailsCatalog(): array
|
|
{
|
|
return Cache::remember(
|
|
self::PRODUCT_DETAILS_CACHE_KEY,
|
|
now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()),
|
|
function (): array {
|
|
try {
|
|
$response = $this->rcReadClient()->get($this->apiUrl.'/products/details.json', [
|
|
'auth-userid' => $this->authUserId,
|
|
'api-key' => $this->apiKey,
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
Log::warning('DomainRegistrarService: product-details failed', [
|
|
'status' => $response->status(),
|
|
'body' => Str::limit($response->body(), 500),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
|
|
$data = $response->json();
|
|
|
|
return is_array($data) ? $data : [];
|
|
} catch (\Throwable $e) {
|
|
Log::warning('DomainRegistrarService: product-details exception', [
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
|
|
return [];
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $tlds
|
|
* @return array<string, array{addnewdomain?: array, addtransferdomain?: array}>
|
|
*/
|
|
private function preloadPricingForTlds(array $tlds): array
|
|
{
|
|
$map = [];
|
|
$normalizedTlds = array_values(array_unique(array_map(
|
|
fn ($tld) => ltrim(strtolower((string) $tld), '.'),
|
|
array_filter($tlds)
|
|
)));
|
|
|
|
if ($normalizedTlds === []) {
|
|
return $map;
|
|
}
|
|
|
|
$table = $this->getCustomerPriceTable();
|
|
|
|
foreach ($normalizedTlds as $tld) {
|
|
$map[$tld] = [
|
|
'addnewdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addnewdomain', 1),
|
|
'addtransferdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addtransferdomain', 1),
|
|
];
|
|
}
|
|
|
|
return $map;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $table
|
|
* @return array{amount_minor?: int, currency?: string, label?: string}
|
|
*/
|
|
private function extractTldPricingFromCustomerTable(array $table, string $tld, string $actionType, int $years = 1): array
|
|
{
|
|
if ($tld === '' || $table === []) {
|
|
return [];
|
|
}
|
|
|
|
$productKey = $this->resolveProductKeyForTld($tld, $table);
|
|
|
|
if ($productKey === null) {
|
|
return [];
|
|
}
|
|
|
|
$entry = $table[$productKey] ?? null;
|
|
|
|
if (! is_array($entry)) {
|
|
return [];
|
|
}
|
|
|
|
$actionData = $entry[$actionType] ?? null;
|
|
|
|
if (! is_array($actionData)) {
|
|
return [];
|
|
}
|
|
|
|
$price = $actionData[(string) $years] ?? null;
|
|
|
|
if (! is_numeric($price)) {
|
|
return [];
|
|
}
|
|
|
|
$amountMinor = (int) round(((float) $price) * 100);
|
|
|
|
return [
|
|
'amount_minor' => $amountMinor,
|
|
'currency' => $this->sellingCurrency,
|
|
'label' => $this->formatMinorLabel($amountMinor, $this->sellingCurrency),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $table
|
|
*/
|
|
private function resolveProductKeyForTld(string $tld, array $table): ?string
|
|
{
|
|
$normalizedTld = ltrim(strtolower($tld), '.');
|
|
$configured = trim((string) ($this->tldProductKeys[$normalizedTld] ?? ''));
|
|
|
|
if ($normalizedTld === '') {
|
|
return null;
|
|
}
|
|
|
|
if ($configured !== '' && isset($table[$configured])) {
|
|
return $configured;
|
|
}
|
|
|
|
$resolved = Cache::remember(
|
|
'rc:domain-product-key:'.$normalizedTld,
|
|
now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()),
|
|
fn (): string => $this->fetchProductKeyFromApi($normalizedTld, $table) ?? self::PRODUCT_KEY_CACHE_MISS
|
|
);
|
|
|
|
return $resolved !== self::PRODUCT_KEY_CACHE_MISS ? $resolved : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $table
|
|
*/
|
|
private function fetchProductKeyFromApi(string $tld, array $table): ?string
|
|
{
|
|
foreach ($this->productDetailsRows($this->getProductDetailsCatalog()) as $row) {
|
|
if (! $this->detailsRowMatchesTld($row['payload'], $tld)) {
|
|
continue;
|
|
}
|
|
|
|
$productKey = $row['product_key'];
|
|
|
|
if ($productKey !== '' && isset($table[$productKey])) {
|
|
return $productKey;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $details
|
|
* @return list<array{product_key: string, payload: array<string, mixed>}>
|
|
*/
|
|
private function productDetailsRows(array $details): array
|
|
{
|
|
$rows = [];
|
|
$this->collectProductDetailsRows($details, $rows);
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $node
|
|
* @param array<int, array{product_key: string, payload: array<string, mixed>}> $rows
|
|
* @param array<string, string> $context
|
|
*/
|
|
private function collectProductDetailsRows(array $node, array &$rows, array $context = []): void
|
|
{
|
|
foreach ($node as $key => $value) {
|
|
if (! is_array($value)) {
|
|
continue;
|
|
}
|
|
|
|
$childContext = $this->mergeProductDetailsContext($context, is_string($key) ? $key : null, $value);
|
|
$payload = array_merge($childContext, $value);
|
|
$productKey = $this->extractProductKeyFromDetailsRow($payload, is_string($key) ? $key : null);
|
|
|
|
if ($productKey !== null && $this->looksLikeProductDetailsRow($payload)) {
|
|
$rows[] = [
|
|
'product_key' => $productKey,
|
|
'payload' => $payload,
|
|
];
|
|
}
|
|
|
|
$this->collectProductDetailsRows($value, $rows, $childContext);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $context
|
|
* @param array<string, mixed> $node
|
|
* @return array<string, string>
|
|
*/
|
|
private function mergeProductDetailsContext(array $context, ?string $key, array $node): array
|
|
{
|
|
$merged = $context;
|
|
|
|
foreach ([
|
|
'category' => ['category', 'categoryname'],
|
|
'group' => ['group', 'groupname', 'productgroup', 'type'],
|
|
] as $target => $fields) {
|
|
foreach ($fields as $field) {
|
|
$value = trim((string) ($node[$field] ?? ''));
|
|
|
|
if ($value !== '') {
|
|
$merged[$target] = strtolower($value);
|
|
|
|
continue 2;
|
|
}
|
|
}
|
|
}
|
|
|
|
$normalizedKey = strtolower(trim((string) $key));
|
|
|
|
if (($merged['category'] ?? '') === '' && $normalizedKey === 'domain') {
|
|
$merged['category'] = 'domain';
|
|
}
|
|
|
|
if (($merged['group'] ?? '') === '' && $normalizedKey === 'tld') {
|
|
$merged['group'] = 'tld';
|
|
}
|
|
|
|
return $merged;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function looksLikeProductDetailsRow(array $row): bool
|
|
{
|
|
foreach ([
|
|
'category',
|
|
'categoryname',
|
|
'group',
|
|
'groupname',
|
|
'productgroup',
|
|
'type',
|
|
'productkey',
|
|
'product-key',
|
|
'product_key',
|
|
'name',
|
|
'productname',
|
|
'product_name',
|
|
'product',
|
|
'slug',
|
|
'tld',
|
|
] as $field) {
|
|
if (array_key_exists($field, $row)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function extractProductKeyFromDetailsRow(array $row, ?string $fallbackKey = null): ?string
|
|
{
|
|
foreach (['productkey', 'product-key', 'product_key', 'key'] as $field) {
|
|
$candidate = trim((string) ($row[$field] ?? ''));
|
|
|
|
if ($candidate !== '') {
|
|
return $candidate;
|
|
}
|
|
}
|
|
|
|
$candidate = trim((string) $fallbackKey);
|
|
|
|
return $candidate !== '' ? $candidate : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function detailsRowMatchesTld(array $row, string $tld): bool
|
|
{
|
|
return $this->detailsRowIsDomainTld($row)
|
|
&& $this->detailsRowContainsTld($row, $tld);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function detailsRowIsDomainTld(array $row): bool
|
|
{
|
|
$category = $this->detailsRowFieldContains($row, ['category', 'categoryname'], 'domain');
|
|
$group = $this->detailsRowFieldContains($row, ['group', 'groupname', 'productgroup', 'type'], 'tld');
|
|
|
|
return $category && $group;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
* @param list<string> $fields
|
|
*/
|
|
private function detailsRowFieldContains(array $row, array $fields, string $needle): bool
|
|
{
|
|
foreach ($fields as $field) {
|
|
$value = strtolower(trim((string) ($row[$field] ?? '')));
|
|
|
|
if ($value !== '' && str_contains($value, $needle)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $row
|
|
*/
|
|
private function detailsRowContainsTld(array $row, string $tld): bool
|
|
{
|
|
$pattern = '/(^|[^a-z0-9])\.?'.preg_quote($tld, '/').'($|[^a-z0-9])/i';
|
|
|
|
foreach (['tld', 'name', 'productname', 'product_name', 'product', 'description', 'slug'] as $field) {
|
|
$value = trim((string) ($row[$field] ?? ''));
|
|
|
|
if ($value === '') {
|
|
continue;
|
|
}
|
|
|
|
if (ltrim(strtolower($value), '.') === $tld || preg_match($pattern, strtolower($value)) === 1) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote
|
|
* @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool}
|
|
*/
|
|
private function preferredDomainPricing(string $domain, string $actionType, array $inlineQuote = []): array
|
|
{
|
|
$tableQuote = $this->extractTldPricingFromCustomerTable(
|
|
$this->getCustomerPriceTable(),
|
|
$this->extractTld($domain),
|
|
$actionType,
|
|
1
|
|
);
|
|
|
|
if (($tableQuote['amount_minor'] ?? 0) > 0) {
|
|
return $tableQuote;
|
|
}
|
|
|
|
return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : [];
|
|
}
|
|
|
|
/**
|
|
* @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote
|
|
* @param array{amount_minor?: int, currency?: string, label?: string} $prefetchedQuote
|
|
* @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool}
|
|
*/
|
|
private function resolveAvailabilityPriceQuote(
|
|
string $domain,
|
|
string $actionType,
|
|
array $inlineQuote = [],
|
|
array $prefetchedQuote = []
|
|
): array {
|
|
if (($prefetchedQuote['amount_minor'] ?? 0) > 0) {
|
|
return $prefetchedQuote;
|
|
}
|
|
|
|
return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : [];
|
|
}
|
|
|
|
private function formatMinorLabel(int $amountMinor, string $currency): string
|
|
{
|
|
return strtoupper($currency).' '.number_format($amountMinor / 100, 2);
|
|
}
|
|
|
|
/**
|
|
* @param array{blocked?: bool} $pricing
|
|
*/
|
|
private function pricingUnavailableMessage(array $pricing, string $context): string
|
|
{
|
|
if ((bool) ($pricing['blocked'] ?? false)) {
|
|
return $context === 'transfer'
|
|
? 'Live transfer pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.'
|
|
: 'Live domain pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.';
|
|
}
|
|
|
|
return $context === 'transfer'
|
|
? 'Transfer pricing is temporarily unavailable. Please try again shortly.'
|
|
: 'Domain pricing is temporarily unavailable. Please try again shortly.';
|
|
}
|
|
|
|
private function isRegistrarSecurityBlock(Response $response): bool
|
|
{
|
|
if (! in_array($response->status(), [403, 429, 503], true)) {
|
|
return false;
|
|
}
|
|
|
|
$body = strtolower((string) $response->body());
|
|
|
|
return str_contains($body, 'cloudflare')
|
|
|| str_contains($body, 'attention required')
|
|
|| str_contains($body, 'you have been blocked')
|
|
|| str_contains($body, 'security check');
|
|
}
|
|
|
|
/**
|
|
* Extract the TLD from a full domain name.
|
|
*/
|
|
private function extractTld(string $domain): string
|
|
{
|
|
$domain = strtolower(trim($domain));
|
|
$dot = strpos($domain, '.');
|
|
|
|
return $dot !== false ? substr($domain, $dot + 1) : '';
|
|
}
|
|
|
|
private function booleanFromResponse(Response $response): bool
|
|
{
|
|
$json = $response->json();
|
|
|
|
if (is_bool($json)) {
|
|
return $json;
|
|
}
|
|
|
|
if (is_string($json)) {
|
|
return filter_var($json, FILTER_VALIDATE_BOOLEAN);
|
|
}
|
|
|
|
$body = strtolower(trim(strip_tags((string) $response->body())));
|
|
|
|
return in_array($body, ['1', 'true', 'yes'], true);
|
|
}
|
|
|
|
private function extractResponseMessage(Response $response, string $fallback): string
|
|
{
|
|
$json = $response->json();
|
|
|
|
if (is_array($json)) {
|
|
foreach (['message', 'error', 'description'] as $key) {
|
|
$value = trim((string) ($json[$key] ?? ''));
|
|
|
|
if ($value !== '') {
|
|
return $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
$body = trim(strip_tags((string) $response->body()));
|
|
|
|
if ($body === '' || str_contains(strtolower((string) $response->body()), '<html')) {
|
|
return $fallback;
|
|
}
|
|
|
|
return Str::limit(preg_replace('/\s+/', ' ', $body) ?? $fallback, 180);
|
|
}
|
|
|
|
private function resolveCurrencyCode(string $symbol): string
|
|
{
|
|
return match (trim($symbol)) {
|
|
'GH₵', 'GHS' => 'GHS',
|
|
'₦', 'NGN' => 'NGN',
|
|
'KSh', 'KES' => 'KES',
|
|
'R', 'ZAR' => 'ZAR',
|
|
'$', 'USD' => 'USD',
|
|
'€', 'EUR' => 'EUR',
|
|
'£', 'GBP' => 'GBP',
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Certain TLDs require -1 for some contact slots during transfer.
|
|
*
|
|
* @return array{0: string, 1: string, 2: string}
|
|
*/
|
|
private function transferContactProfileForDomain(string $domain, string $defaultContactId): array
|
|
{
|
|
$tld = '.'.strtolower($this->extractTld($domain));
|
|
|
|
$admin = $defaultContactId;
|
|
$tech = $defaultContactId;
|
|
$billing = $defaultContactId;
|
|
|
|
if (in_array($tld, ['.eu', '.ru', '.uk'], true)) {
|
|
$admin = '-1';
|
|
}
|
|
|
|
if (in_array($tld, ['.ru', '.uk'], true)) {
|
|
$tech = '-1';
|
|
}
|
|
|
|
if (in_array($tld, ['.at', '.berlin', '.ca', '.nl', '.nz', '.ru', '.uk'], true)) {
|
|
$billing = '-1';
|
|
}
|
|
|
|
return [$admin, $tech, $billing];
|
|
}
|
|
}
|