From e335a3684c75fed89d9caefb1cd164515eb9c4de Mon Sep 17 00:00:00 2001 From: Harry Lewis Date: Fri, 17 Feb 2023 15:35:50 +0000 Subject: [PATCH] Initial public release --- .gitignore | 5 + .php-cs-fixer.dist.php | 12 + .phpcs.xml | 21 + CHANGELOG.md | 7 + CONTRIBUTING.md | 52 + LICENSE.md | 674 ++++++++++ README.md | 93 ++ composer.json | 43 + src/Category.php | 98 ++ src/CoccaEpp/Client.php | 248 ++++ src/CoccaEpp/Data/Configuration.php | 33 + src/CoccaEpp/Helper/EppHelper.php | 11 + src/CoccaEpp/Provider.php | 848 ++++++++++++ src/CoccaEpp/default_cert.pem | 58 + .../Data/ConnectResellerConfiguration.php | 28 + src/ConnectReseller/Provider.php | 861 ++++++++++++ src/Data/AutoRenewParams.php | 27 + src/Data/ContactData.php | 41 + src/Data/ContactParams.php | 43 + src/Data/ContactResult.php | 45 + src/Data/DacDomain.php | 88 ++ src/Data/DacParams.php | 24 + src/Data/DacResult.php | 25 + src/Data/DomainContactInfo.php | 43 + src/Data/DomainInfoParams.php | 25 + src/Data/DomainNotification.php | 129 ++ src/Data/DomainResult.php | 71 + src/Data/EppCodeResult.php | 23 + src/Data/EppParams.php | 25 + src/Data/IpsTagParams.php | 27 + src/Data/LockParams.php | 27 + src/Data/Nameserver.php | 25 + src/Data/NameserversParams.php | 31 + src/Data/NameserversResult.php | 31 + src/Data/PollParams.php | 23 + src/Data/PollResult.php | 25 + src/Data/RegisterContactParams.php | 25 + src/Data/RegisterDomainParams.php | 37 + src/Data/RenewParams.php | 27 + src/Data/TransferParams.php | 31 + src/Data/UpdateDomainContactParams.php | 27 + src/Data/UpdateNameserversParams.php | 35 + .../Data/DomainNameApiConfiguration.php | 29 + src/DomainNameApi/Provider.php | 473 +++++++ src/Enom/Data/Configuration.php | 29 + src/Enom/Helper/EnomApi.php | 739 +++++++++++ src/Enom/Provider.php | 497 +++++++ src/Helper/Countries.php | 356 +++++ src/Helper/Utils.php | 274 ++++ src/Hexonet/Dac.php | 245 ++++ src/Hexonet/Data/Configuration.php | 29 + src/Hexonet/EppExtension/EppConnection.php | 97 ++ .../Requests/EppCheckTransferRequest.php | 48 + .../Requests/EppQueryTransferListRequest.php | 65 + .../Requests/EppTransferRequest.php | 49 + .../Responses/EppCheckTransferResponse.php | 52 + .../EppQueryTransferListResponse.php | 78 ++ src/Hexonet/Helper/EppHelper.php | 1004 ++++++++++++++ src/Hexonet/Helper/HexonetApi.php | 307 +++++ src/Hexonet/Helper/HexonetLogger.php | 46 + src/Hexonet/Provider.php | 899 +++++++++++++ src/LaravelServiceProvider.php | 49 + src/LogicBoxes/Data/Configuration.php | 27 + src/LogicBoxes/Provider.php | 1103 ++++++++++++++++ src/NameSilo/Data/NameSiloConfiguration.php | 25 + src/NameSilo/Provider.php | 1166 +++++++++++++++++ src/NetEarthOne/Provider.php | 23 + src/Nira/Data/Configuration.php | 22 + src/Nira/Provider.php | 49 + src/Nira/cert.pem | 58 + src/Nominet/Data/NominetConfiguration.php | 29 + .../EppExtension/NominetConnection.php | 75 ++ .../EppExtension/eppCreateContactRequest.php | 78 ++ .../EppExtension/eppHandshakeRequest.php | 29 + .../EppExtension/eppHandshakeResponse.php | 40 + .../EppExtension/eppInfoContactResponse.php | 42 + src/Nominet/EppExtension/eppPollResponse.php | 106 ++ .../EppExtension/eppReleaseRequest.php | 52 + .../EppExtension/eppReleaseResponse.php | 46 + src/Nominet/Provider.php | 830 ++++++++++++ .../Data/OpenProviderConfiguration.php | 29 + src/OpenProvider/Provider.php | 982 ++++++++++++++ src/OpenSRS/Data/OpenSrsConfiguration.php | 27 + src/OpenSRS/Helper/OpenSrsApi.php | 472 +++++++ src/OpenSRS/Provider.php | 817 ++++++++++++ src/ResellBiz/Provider.php | 21 + src/ResellerClub/Provider.php | 22 + src/Ricta/Data/Configuration.php | 22 + src/Ricta/Provider.php | 49 + src/Ricta/cert.pem | 58 + .../Data/UGRegistryConfiguration.php | 25 + src/UGRegistry/Provider.php | 580 ++++++++ 92 files changed, 16341 insertions(+) create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 .phpcs.xml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Category.php create mode 100644 src/CoccaEpp/Client.php create mode 100644 src/CoccaEpp/Data/Configuration.php create mode 100644 src/CoccaEpp/Helper/EppHelper.php create mode 100644 src/CoccaEpp/Provider.php create mode 100644 src/CoccaEpp/default_cert.pem create mode 100644 src/ConnectReseller/Data/ConnectResellerConfiguration.php create mode 100644 src/ConnectReseller/Provider.php create mode 100644 src/Data/AutoRenewParams.php create mode 100644 src/Data/ContactData.php create mode 100644 src/Data/ContactParams.php create mode 100644 src/Data/ContactResult.php create mode 100644 src/Data/DacDomain.php create mode 100644 src/Data/DacParams.php create mode 100644 src/Data/DacResult.php create mode 100644 src/Data/DomainContactInfo.php create mode 100644 src/Data/DomainInfoParams.php create mode 100644 src/Data/DomainNotification.php create mode 100644 src/Data/DomainResult.php create mode 100644 src/Data/EppCodeResult.php create mode 100644 src/Data/EppParams.php create mode 100644 src/Data/IpsTagParams.php create mode 100644 src/Data/LockParams.php create mode 100644 src/Data/Nameserver.php create mode 100644 src/Data/NameserversParams.php create mode 100644 src/Data/NameserversResult.php create mode 100644 src/Data/PollParams.php create mode 100644 src/Data/PollResult.php create mode 100644 src/Data/RegisterContactParams.php create mode 100644 src/Data/RegisterDomainParams.php create mode 100644 src/Data/RenewParams.php create mode 100644 src/Data/TransferParams.php create mode 100644 src/Data/UpdateDomainContactParams.php create mode 100644 src/Data/UpdateNameserversParams.php create mode 100644 src/DomainNameApi/Data/DomainNameApiConfiguration.php create mode 100644 src/DomainNameApi/Provider.php create mode 100644 src/Enom/Data/Configuration.php create mode 100644 src/Enom/Helper/EnomApi.php create mode 100644 src/Enom/Provider.php create mode 100644 src/Helper/Countries.php create mode 100644 src/Helper/Utils.php create mode 100644 src/Hexonet/Dac.php create mode 100644 src/Hexonet/Data/Configuration.php create mode 100644 src/Hexonet/EppExtension/EppConnection.php create mode 100644 src/Hexonet/EppExtension/Requests/EppCheckTransferRequest.php create mode 100644 src/Hexonet/EppExtension/Requests/EppQueryTransferListRequest.php create mode 100644 src/Hexonet/EppExtension/Requests/EppTransferRequest.php create mode 100644 src/Hexonet/EppExtension/Responses/EppCheckTransferResponse.php create mode 100644 src/Hexonet/EppExtension/Responses/EppQueryTransferListResponse.php create mode 100644 src/Hexonet/Helper/EppHelper.php create mode 100644 src/Hexonet/Helper/HexonetApi.php create mode 100644 src/Hexonet/Helper/HexonetLogger.php create mode 100644 src/Hexonet/Provider.php create mode 100644 src/LaravelServiceProvider.php create mode 100644 src/LogicBoxes/Data/Configuration.php create mode 100644 src/LogicBoxes/Provider.php create mode 100644 src/NameSilo/Data/NameSiloConfiguration.php create mode 100644 src/NameSilo/Provider.php create mode 100644 src/NetEarthOne/Provider.php create mode 100644 src/Nira/Data/Configuration.php create mode 100644 src/Nira/Provider.php create mode 100644 src/Nira/cert.pem create mode 100644 src/Nominet/Data/NominetConfiguration.php create mode 100644 src/Nominet/EppExtension/NominetConnection.php create mode 100644 src/Nominet/EppExtension/eppCreateContactRequest.php create mode 100644 src/Nominet/EppExtension/eppHandshakeRequest.php create mode 100644 src/Nominet/EppExtension/eppHandshakeResponse.php create mode 100644 src/Nominet/EppExtension/eppInfoContactResponse.php create mode 100644 src/Nominet/EppExtension/eppPollResponse.php create mode 100644 src/Nominet/EppExtension/eppReleaseRequest.php create mode 100644 src/Nominet/EppExtension/eppReleaseResponse.php create mode 100644 src/Nominet/Provider.php create mode 100644 src/OpenProvider/Data/OpenProviderConfiguration.php create mode 100644 src/OpenProvider/Provider.php create mode 100644 src/OpenSRS/Data/OpenSrsConfiguration.php create mode 100644 src/OpenSRS/Helper/OpenSrsApi.php create mode 100644 src/OpenSRS/Provider.php create mode 100644 src/ResellBiz/Provider.php create mode 100644 src/ResellerClub/Provider.php create mode 100644 src/Ricta/Data/Configuration.php create mode 100644 src/Ricta/Provider.php create mode 100644 src/Ricta/cert.pem create mode 100644 src/UGRegistry/Data/UGRegistryConfiguration.php create mode 100644 src/UGRegistry/Provider.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd3c6e6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +vendor +composer.lock +.idea +.vscode +.php-cs-fixer.cache \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..d7d7ab9 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,12 @@ +in(__DIR__ . '/src'); + +return (new PhpCsFixer\Config()) + ->setFinder($finder) + ->setRules([ + '@PSR12' => true, + 'array_syntax' => ['syntax' => 'short'], + 'concat_space' => ['spacing' => 'one'], + ]); diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..666a2f0 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,21 @@ + + + + +Enforce PSR-12 style standard on Upmind repositories + + + + + \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ec71969 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +All notable changes to the package will be documented in this file. + +## v2.0 - 2023-02-17 + +Initial public release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b70dae0 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +Please read and understand the contribution guide before creating an issue or pull request. + +## Etiquette + +This project is open source, and as such, the maintainers give their free time to build and maintain the source code +held within. They make the code freely available in the hope that it will be of use to other developers. It would be +extremely unfair for them to suffer abuse or anger for their hard work. + +Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the +world that developers are civilized and selfless people. + +It's the duty of the maintainer to ensure that all submissions to the project are of sufficient +quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. + +## Viability + +When requesting or submitting new features, first consider whether it might be useful to others. Open +source projects are used by many developers, who may have entirely different needs to your own. Think about +whether or not your feature is likely to be used by other users of the project. + +## Procedure + +Before filing an issue: + +- Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. +- Check to make sure your feature suggestion isn't already present within the project. +- Check the pull requests tab to ensure that the feature/fix isn't already in progress. + +Before submitting a pull request: + +- Check the codebase to ensure that your feature doesn't already exist. +- Check the pull requests to ensure that another person hasn't already submitted the feature or fix. + +## Requirements + +If the project maintainer has any additional requirements, you will find them listed here. + +- **[PSR-12 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-12-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + +**Happy coding**! \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..7f4f9f2 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + Provision Providers - Domain Names + Copyright (C) 2022 Upmind Provisioning + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Provision Providers - Domain Names Copyright (C) 2022 Upmind Provisioning + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eafec60 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# Upmind Provision Providers - Domain Names + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/upmind/provision-provider-domain-names.svg?style=flat-square)](https://packagist.org/packages/upmind/provision-provider-domain-names) + +This provision category contains common functions used in domain name provisioning flows with various registries and registrar/reseller platforms. + +- [Installation](#installation) +- [Usage](#usage) + - [Quick-start](#quick-start) +- [Supported Providers](#supported-providers) +- [Functions](#functions) +- [Changelog](#changelog) +- [Contributing](#contributing) +- [Credits](#credits) +- [License](#license) +- [Upmind](#upmind) + +## Installation + +```bash +composer require upmind/provision-provider-domain-names +``` + +## Usage + +This library makes use of [upmind/provision-provider-base](https://packagist.org/packages/upmind/provision-provider-base) primitives which we suggest you familiarize yourself with by reading the usage section in the README. + +### Quick-start + +The easiest way to see this provision category in action and to develop/test changes is to install it in [upmind/provision-workbench](https://github.com/upmind-automation/provision-workbench#readme). + +Alternatively you can start using it for your business immediately with [Upmind.com](https://upmind.com/start) - the ultimate web hosting billing and management solution. + +## Supported Providers + +The following providers are currently implemented: + - [OpenSRS](https://domains.opensrs.guide/docs/quickstart) + - [Hexonet](https://wiki.hexonet.net/wiki/Domain_API) + - [Nominet](https://registrars.nominet.uk/uk-namespace/registration-and-domain-management/registration-systems/epp/epp-commands/) + - [NameSilo](https://www.namesilo.com/api-reference#domains/register-domain) + - [OpenProvider](https://docs.openprovider.com/doc/all#tag/descDomainQuickstart) + - [ConnectReseller](https://www.connectreseller.com/integration-options/#api) + - [DomainNameApi](https://www.domainnameapi.com/domain-reseller-api) + - [Enom](https://cp.enom.com/APICommandCatalog/API%20topics/api_Command_Categories.htm) + - [LogicBoxes](https://manage.logicboxes.com/kb/servlet/KBServlet/cat119.html) + - [ResellerClub](https://manage.resellerclub.com/kb/servlet/KBServlet/cat119.html) + - [NetEarthOne](https://manage.netearthone.com/kb/servlet/KBServlet/cat119.html) + - [Resell.biz](https://cp.us2.net/kb/servlet/KBServlet/cat119.html) + - [CoCCA](https://cocca.org.nz/) + - [NIRA](https://nira.ng/become-a-registrar) + - [Ricta](https://www.ricta.org.rw/become-a-registrar/) + - [UGRegistry](https://registry.co.ug/docs/v2/) + +## Functions + +| Function | Parameters | Return Data | Description | +|---|---|---|---| +| poll() | [_PollParams_](src/Data/PollParams.php) | [_PollResult_](src/Data/PollResult.php) | Poll for the latest relevant domain event notifications e.g., successful transfer-in, domain deletion etc | +| domainAvailabilityCheck() | [_DacParams_](src/Data/DacParams.php) | [_DacResult_](src/Data/DacResult.php) | Check the availability of a domain SLD across one or more TLDs | +| register() | [_RegisterDomainParams_](src/Data/RegisterDomainParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Register a new domain name | +| transfer() | [_TransferParams_](src/Data/TransferParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Initiate and/or check a domain name transfer, returning successfully if transfer is complete | +| renew() | [_RenewParams_](src/Data/RenewParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Renew a domain name for a given number of years | +| getInfo() | [_DomainInfoParams_](src/Data/DomainInfoParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Get information about a domain name including status, expiry date, nameservers, contacts etc | +| updateRegistrantContact() | [_UpdateDomainContactParams_](src/Data/UpdateDomainContactParams.php) | [_ContactResult_](src/Data/ContactResult.php) | Update the registrant contact details of a domain name | +| updateNameservers() | [_UpdateNameserversParams_](src/Data/UpdateNameserversParams.php) | [_NameserversResult_](src/Data/NameserversResult.php) | Update a domain's nameservers | +| setLock() | [_LockParams_](src/Data/LockParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Lock or unlock a domain name for transfers and changes | +| setAutoRenew() | [_AutoRenewParams_](src/Data/AutoRenewParams.php) | [_DomainResult_](src/Data/DomainResult.php) | Toggle registry auto-renewal for a domain name | +| getEppCode() | [_EppParams_](src/Data/EppParams.php) | [_EppCodeResult_](src/Data/EppCodeResult.php) | Get the EPP/Auth code of a domain name | +| updateIpsTag() | [_IpsTagParams_](src/Data/IpsTagParams.php) | [_ResultData_](src/Data/ResultData.php) | Release a domain name to a new IPS tag (UK-only) | + +## Changelog + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## Credits + + - [Harry Lewis](https://github.com/uphlewis) + - [Nayden Panchev](https://github.com/airnayden) + - [Ivaylo Georgiev](https://github.com/Georgiev-Ivaylo) + - [Nikolai Arsov](https://github.com/nikiarsov777) + - [All Contributors](../../contributors) + +## License + +GNU General Public License version 3 (GPLv3). Please see [License File](LICENSE.md) for more information. + +## Upmind + +Sell, manage and support web hosting, domain names, ssl certificates, website builders and more with [Upmind.com](https://upmind.com/start). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..dbaa38f --- /dev/null +++ b/composer.json @@ -0,0 +1,43 @@ +{ + "name": "upmind/provision-provider-domain-names", + "description": "This provision category contains common functions used in domain name provisioning flows with various registries and registrar/reseller platforms.", + "type": "library", + "authors": [ + { + "name": "Nayden Panchev", + "email": "nayden@upmind.com" + }, + { + "name": "Ivaylo Georgiev", + "email": "ivaylo@upmind.com" + }, + { + "name": "Harry Lewis", + "email": "harry@upmind.com" + } + ], + "autoload": { + "psr-4": { + "Upmind\\ProvisionProviders\\DomainNames\\": "src/" + } + }, + "require": { + "upmind/provision-provider-base": "^3.0", + "metaregistrar/php-epp-client": "^1.0", + "hexonet/php-sdk": "^5.0", + "propaganistas/laravel-phone": "^4.2", + "guzzlehttp/guzzle": "^6.3|^7.0", + "pragmarx/countries": "^0.8.2", + "africc/php-epp2": "^1.0", + "upmind/domainnameapi-sdk": "^1.0" + }, + "extra": { + "laravel": { + "providers": [ + "Upmind\\ProvisionProviders\\DomainNames\\LaravelServiceProvider" + ] + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/src/Category.php b/src/Category.php new file mode 100644 index 0000000..34c75ff --- /dev/null +++ b/src/Category.php @@ -0,0 +1,98 @@ +setName('Domain Names') + ->setDescription('Register, transfer, renew and manage domain names through various registries and registrar/reseller platforms') + ->setIcon('world'); + } + + /** + * Poll for the latest relevant domain event notifications e.g., successful transfer-in, domain deletion etc. + */ + abstract public function poll(PollParams $params): PollResult; + + /** + * Check the availability of a domain SLD across one or more TLDs. + */ + abstract public function domainAvailabilityCheck(DacParams $params): DacResult; + + /** + * Register a new domain name. + */ + abstract public function register(RegisterDomainParams $params): DomainResult; + + /** + * Initiate and/or check a domain name transfer, returning successfully if transfer is complete. + */ + abstract public function transfer(TransferParams $params): DomainResult; + + /** + * Renew a domain name for a given number of years. + */ + abstract public function renew(RenewParams $params): DomainResult; + + /** + * Get information about a domain name including status, expiry date, nameservers, contacts etc. + */ + abstract public function getInfo(DomainInfoParams $params): DomainResult; + + /** + * Update the registrant contact details of a domain name. + */ + abstract public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult; + + /** + * Update a domain's nameservers + */ + abstract public function updateNameservers(UpdateNameserversParams $params): NameserversResult; + + /** + * Lock or unlock a domain name for transfers and changes. + */ + abstract public function setLock(LockParams $params): DomainResult; + + /** + * Toggle registry auto-renewal for a domain name. + */ + abstract public function setAutoRenew(AutoRenewParams $params): DomainResult; + + /** + * Get the EPP/Auth code of a domain name. + */ + abstract public function getEppCode(EppParams $params): EppCodeResult; + + /** + * Release a domain name to a new IPS tag (UK-only). + */ + abstract public function updateIpsTag(IpsTagParams $params): ResultData; +} diff --git a/src/CoccaEpp/Client.php b/src/CoccaEpp/Client.php new file mode 100644 index 0000000..004ce7d --- /dev/null +++ b/src/CoccaEpp/Client.php @@ -0,0 +1,248 @@ +username = $username; + $this->password = $password; + $this->host = $host; + $this->logger = $logger; + + parent::__construct(array_merge([ + 'username' => $username, + 'password' => $password, + 'host' => $host, + 'port' => $port ?? 700, + 'ssl' => isset($certPath), + 'local_cert' => $certPath, + 'debug' => isset($logger), + 'services' => [ + 'urn:ietf:params:xml:ns:obj1', + 'urn:ietf:params:xml:ns:obj2', + 'urn:ietf:params:xml:ns:obj3', + ], + 'serviceExtensions' => [ + 'http://custom/obj1ext-1.0' + ], + ], $additionalConfig)); + } + + public function sendFrame(FrameInterface $frame) + { + try { + return parent::sendFrame($frame); + } catch (Exception $e) { + throw $this->error( + 'Unexpected Registry Network Error', + $e, + ['frame' => get_class($frame)], + ['frame_content' => $frame->__toString()] + ); + } + } + + /** + * @param string $message + * + * @return void + */ + protected function log($message, $color = '0;32') + { + if (isset($this->logger)) { + if (is_string($message)) { + // remove binary header + $header = mb_substr($message, 0, 4); + if (false === mb_detect_encoding($header, null, true)) { + $message = mb_substr($message, 4, mb_strlen($message) - 4); + } + + // and another try in-case the binary header appared to be valid utf8 or ascii or !== 4 bytes + if (is_string($message) && preg_match('/(.{0,16})<\\?xml version/', $message, $matches)) { + $message = Str::replaceFirst($matches[1], '', $message); + } + } + + if (!empty($message)) { + $this->logger->debug( + sprintf( + 'CoCCA [%s]: %s', + $color === '1;31' ? 'SEND' : 'RECV', + $this->replaceSensitive($this->prettifyXml($message)) + ), + [ + 'host' => $this->host, + 'username' => $this->username, + ] + ); + } + } + } + + /** + * @inheritDoc + */ + protected function generateClientTransactionId() + { + return mt_rand() . mt_rand(); + } + + /** + * @inheritDoc + */ + protected function login($newPassword = false) + { + try { + parent::login(); + $this->loggedIn = true; + } catch (Exception $e) { + throw $this->error( + sprintf( + 'Registry Auth Error: %s', + trim(Str::replaceFirst('Authentication error;', '', $e->getMessage())) + ), + $e + ); + } + } + + public function connect() + { + try { + return parent::connect(); + } catch (Exception $e) { + if (Str::contains($e->getMessage(), ['Timeout', 'timeout', 'timed out'])) { + throw $this->error( + sprintf('Registry Connection Error: %s', $e->getMessage()), + $e + ); + } + + throw $e; + } + } + + /** + * @inheritDoc + */ + public function close() + { + if ($this->loggedIn) { + $this->loggedIn = false; + return parent::close(); + } + + if (is_resource($this->socket)) { + return fclose($this->socket); + } + + return false; + } + + /** + * @throws ProvisionFunctionError + * + * @return no-return + */ + protected function error(string $message, Throwable $previous, array $data = [], array $debug = []) + { + throw (new ProvisionFunctionError($this->replaceSensitive($message), 0, $previous)) + ->withData($this->replaceSensitive($data)) + ->withDebug($this->replaceSensitive($debug)); + } + + protected function replaceSensitive($data) + { + if ($data instanceof Arrayable) { + $data = $data->toArray(); + } + + if ($data instanceof stdClass) { + $data = (array)$data; + } + + if (is_array($data)) { + foreach ($data as $k => $v) { + $data[$k] = $this->replaceSensitive($v); + } + return $data; + } + + if (is_string($data)) { + $data = str_replace( + [$this->username, $this->password], + ['[USERNAME]', '[PASSWORD]'], + $data + ); + } + + return $data; + } + + /** + * @param string|mixed $xml + * + * @return string|mixed + */ + protected function prettifyXml($xml) + { + try { + $dom = new \DOMDocument('1.0'); + $dom->formatOutput = true; + $dom->loadXml($xml); + + return $dom->saveXML() ?: $xml; + } catch (Throwable $e) { + return $xml; + } + } +} diff --git a/src/CoccaEpp/Data/Configuration.php b/src/CoccaEpp/Data/Configuration.php new file mode 100644 index 0000000..464516b --- /dev/null +++ b/src/CoccaEpp/Data/Configuration.php @@ -0,0 +1,33 @@ + ['required', 'string', 'min:1'], + 'epp_password' => ['required', 'string', 'min:6'], + 'hostname' => ['required', 'string', 'domain_name'], + 'port' => ['nullable', 'integer', 'min:1'], + 'certificate' => ['nullable', 'certificate_pem'], + 'supported_tlds' => ['nullable', 'string'], + ]); + } +} diff --git a/src/CoccaEpp/Helper/EppHelper.php b/src/CoccaEpp/Helper/EppHelper.php new file mode 100644 index 0000000..dd546ab --- /dev/null +++ b/src/CoccaEpp/Helper/EppHelper.php @@ -0,0 +1,11 @@ +configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('CoCCA EPP') + ->setDescription('Register, transfer, renew and manage CoCCA registry domains such as .ng and .co.ke'); + } + + protected function makeClient(): Client + { + return new Client( + $this->configuration->epp_username, + $this->configuration->epp_password, + $this->configuration->hostname, + intval($this->configuration->port) ?: null, + $this->configuration->certificate + ? $this->getCertificatePath($this->configuration->certificate) + : __DIR__ . '/default_cert.pem', + $this->getLogger() + ); + } + + protected function getClient(): Client + { + if (isset($this->client)) { + return $this->client; + } + + $client = $this->makeClient(); + $client->connect(); + + return $this->client = $client; + } + + /** + * Returns an array of normalized TLDs this provider supports. + * + * @return string[] + */ + protected function getSupportedTlds(): array + { + // Get supported TLDs from configuration + + $tlds = collect(explode(',', $this->configuration->supported_tlds ?? '')) + ->map(function ($tld) { + return Utils::normalizeTld(trim($tld)); + }) + ->filter() + ->values() + ->all(); + + if ($tlds) { + return $tlds; + } + + // Get default TLDs from recognised registrars + + switch ($this->configuration->hostname) { + case 'registry.nic.net.ng': + return ['ng']; + case 'registry.ricta.org.rw': + return ['rw']; + } + + // Last resort + + return [Arr::last(explode('.', $this->configuration->hostname))]; + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + $dacDomains = []; + + $supportedTlds = $this->getSupportedTlds(); + + $sendRequest = false; + $checkRequest = new \AfriCC\EPP\Frame\Command\Check\Domain(); + foreach ($params->tlds as $tld) { + if (!in_array(Utils::getRootTld($tld), $supportedTlds)) { + $dacDomains[] = new DacDomain([ + 'domain' => Utils::getDomain($params->sld, $tld), + 'tld' => Str::start(Utils::normalizeTld($tld), '.'), + 'can_register' => false, + 'can_transfer' => false, + 'is_premium' => false, + 'description' => 'TLD not supported', + ]); + + continue; + } + + $sendRequest = true; + $checkRequest->addDomain(Utils::getDomain($params->sld, $tld)); + } + + if ($sendRequest) { + /** @var MessageQueue $xmlResult */ + $xmlResult = $this->getClient()->request($checkRequest); + + $this->checkResponse($xmlResult); + + $checkDomains = $xmlResult->data()['chkData']['cd']; + if (Arr::isAssoc($checkDomains)) { + // this happens when there's only one result + $checkDomains = [$checkDomains]; + } + } + + + foreach ($checkDomains ?? [] as $chk) { + $canRegister = boolval($chk['@name']['avail']); + $canTransfer = false; + $isPremium = false; + + if (isset($chk['reason']) && preg_match('/^\((\d+)\)/', $chk['reason'], $matches)) { + $canTransfer = $matches[1] === '00'; + } + + $dacDomains[] = new DacDomain([ + 'domain' => $chk['name'], + 'tld' => Str::start(Utils::getTld($chk['name']), '.'), + 'can_register' => $canRegister, + 'can_transfer' => $canTransfer, + 'is_premium' => $isPremium, + 'description' => $chk['reason'] ?? ($canRegister ? 'Available' : 'Not Available'), + ]); + } + + return new DacResult([ + 'domains' => $dacDomains, + ]); + } + + /** + * @param PollParams $params + * @return PollResult + */ + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not currently supported'); + + $since = $params->after_date ? Carbon::parse($params->after_date) : null; + $notifications = []; + $countRemaining = 0; + + /** + * Start a timer because there may be 1000s of irrelevant messages and we should try and avoid a timeout. + */ + $timeLimit = 60; // 60 seconds + $startTime = time(); + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Poll(); + $infoFrame->request(); + /** @var MessageQueue $polls */ + $polls = $client->request($infoFrame); + $countRemaining = $polls->queueCount(); + $messages = $polls->queueMessage(); + $count = $polls->queueMessage()->count(); + $limit = ($params->limit < $count) ? $params->limit : $count; + + try { + while (count($notifications) < $limit && (time() - $startTime) < $timeLimit) { + foreach ($messages as $message) { + $doc = new DOMDocument(); + $doc->loadXML($message->textContent); + $notification = DomainNotification::create() + ->setId($polls->queueId()) + ->setType($this->mapType($doc->getElementsByTagName('details')->item(0)->nodeValue)) + ->setMessage($polls->message()) + ->setDomains([$doc->getElementsByTagName('name')->item(0)->nodeValue]) + ->setCreatedAt(Carbon::parse($polls->queueDate())) + ->setExtra(['xml' => $message->textContent]); + $notifications[] = $notification; + } + } + } catch (\Throwable $e) { + $data = []; + return $this->errorResult('Error encountered while polling for domain notifications', $data, [], $e); + } + + return new PollResult([ + 'count_remaining' => $countRemaining, + 'notifications' => $notifications, + ]); + } + + /** + * @param RegisterDomainParams $params + * @return DomainResult + * @throws \Propaganistas\LaravelPhone\Exceptions\NumberParseException + */ + public function register(RegisterDomainParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Create\Domain(); + $infoFrame->setDomain($domainName); + $infoFrame->setPeriod($params->renew_years . 'y'); + $infoFrame->setRegistrant($this->createContact($params->registrant->register)); + $infoFrame->setAdminContact($this->createContact($params->admin->register)); + $infoFrame->setBillingContact($this->createContact($params->billing->register)); + $infoFrame->setTechContact($this->createContact($params->tech->register)); + foreach ($params->nameservers->toArray() as $key => $ns) { + $infoFrame->addHostObj($ns['host']); + } + + $xmlResponse = $client->request($infoFrame); + $this->checkResponse($xmlResponse); + + return $this->_getDomain($domainName); + } + + /** + * @param TransferParams $params + * @return DomainResult + * @throws \Exception + */ + public function transfer(TransferParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Transfer\Domain(); + $infoFrame->setDomain($domainName); + $infoFrame->setAuthInfo($params->epp_code); + $infoFrame->setOperation('request'); + + $xmlResponse = $client->request($infoFrame); + $this->checkResponse($xmlResponse); + + return $this->_getDomain($domainName); + } + + /** + * @param RenewParams $params + * @return DomainResult + * @throws \Exception + */ + public function renew(RenewParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $client = $this->getClient(); + $domainInfo = $this->_getDomain($domainName); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Renew\Domain(); + $infoFrame->setDomain($domainName); + $infoFrame->setPeriod($params->renew_years . 'y'); + $infoFrame->setCurrentExpirationDate(substr($domainInfo->expires_at, 0, 10)); + + $xmlResponse = $client->request($infoFrame); + $this->checkResponse($xmlResponse); + + return $this->_getDomain($domainName); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + return $this->_getDomain($domainName); + } + + /** + * @param EppParams $params + * @return EppCodeResult + * @throws \Exception + */ + public function getEppCode(EppParams $params): EppCodeResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Info\Domain(); + $infoFrame->setDomain($domainName); + + $xmlResponse = $client->request($infoFrame); + + $domainData = $xmlResponse->data(); + + if (empty($domainData) || !isset($domainData['infData'])) { + $codeRes = $xmlResponse->getElementsByTagName('result')->item(0)->getAttribute('code'); + $msg = $xmlResponse->getElementsByTagName('msg')->item(0)->nodeValue; + + throw $this->errorResult( + 'Unable to obtain EPP code for this domain', + ['code' => $codeRes, 'msg' => $msg, 'data' => $domainData], + ['xml' => (string)$xmlResponse] + ); + } + + return EppCodeResult::create([ + 'epp_code' => ($domainData['infData']['authInfo']['pw']) + ])->setMessage('EPP/Auth code obtained'); + } + + /** + * @param LockParams $params + * @return DomainResult + * @throws \Exception + */ + public function setLock(LockParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Update\Domain(); + $infoFrame->setDomain($domainName); + + if ($params->lock) { + $infoFrame->addStatus('clientDeleteProhibited', 'Locked'); + $infoFrame->addStatus('clientTransferProhibited', 'Transfer Locked'); + } else { + $infoFrame->removeStatus('clientDeleteProhibited'); + $infoFrame->removeStatus('clientTransferProhibited'); + } + + $client->request($infoFrame); + + return $this->_getDomain($domainName); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('The requested operation not supported', $params); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported'); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $domainInfo = $this->_getDomain($domainName); + + $existingNameservers = []; + $newNameservers = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if ($existingNs = $domainInfo->ns->{'ns' . $i}->host ?? null) { + $existingNameservers[] = $existingNs; + } + if ($newNs = $params->{'ns' . $i}->host ?? null) { + $newNameservers[] = $newNs; + } + } + + $updateFrame = new \AfriCC\EPP\Frame\Command\Update\Domain(); + $updateFrame->setDomain($domainName); + + // add + foreach (array_diff($newNameservers, $existingNameservers) as $ns) { + $this->addNameserverHost($ns); + $updateFrame->addHostObj($ns); + } + + // remove + foreach (array_diff($existingNameservers, $newNameservers) as $ns) { + $updateFrame->addHostObj($ns, true); + } + + $xmlResult = $this->getClient()->request($updateFrame); + $this->checkResponse($xmlResult); + + $result = []; + foreach ($newNameservers as $i => $ns) { + $result['ns' . ($i + 1)] = ['host' => $ns]; + } + + return NameserversResult::create($result) + ->setMessage('Nameservers updated'); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + return $this->updateContact($params->sld, $params->tld, $params->contact, EppHelper::CONTACT_TYPE_REGISTRANT); + } + + /** + * Returns a filesystem path to use for the given certificate PEM, creating + * the file if necessary. + * + * @param string|null $certificate Certificate PEM + * + * @return string|null Filesystem path to the certificate + */ + protected function getCertificatePath(?string $certificate): ?string + { + if (empty($certificate)) { + return null; + } + + $path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . sha1($certificate); + + if (!file_exists($path)) { + file_put_contents($path, $certificate, LOCK_EX); + } + + return $path; + } + + /** + * @return ContactResult[] [registrant, billing, administrative, technical] + * @throws \Exception + */ + protected function _allContactInfo( + string $registrantId, + ?string $billingId, + ?string $adminId, + ?string $techId + ): array { + $promises = [ + 'registrant' => $this->_getContactInfo($registrantId), + 'billing' => $billingId ? $this->_getContactInfo($billingId) : null, + 'administrative' => $adminId ? $this->_getContactInfo($adminId) : null, + 'technical' => $techId ? $this->_getContactInfo($techId) : null, + ]; + + return PromiseUtils::all($promises)->wait(); + } + + /** + * @param string $contactId + * @return PromiseInterface + * @throws \Exception + */ + protected function _getContactInfo(string $contactId): ContactResult + { + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Info\Contact(); + $infoFrame->setId($contactId); + + $xmlResponse = $client->request($infoFrame); + $contactData = $xmlResponse->data(); + + if (empty($contactData) || !isset($contactData['infData'])) { + $codeRes = $xmlResponse->getElementsByTagName('result')->item(0)->getAttribute('code'); + $msg = $xmlResponse->getElementsByTagName('msg')->item(0)->nodeValue; + + throw $this->errorResult( + 'Unable to obtain contact data', + ['code' => $codeRes, 'msg' => $msg, 'contact_id' => $contactId], + ['xml' => (string)$xmlResponse] + ); + } + + return $this->_parseContactInfo($contactData); + } + + /** + * @param array $contact + * @return ContactResult + */ + protected function _parseContactInfo(array $contact): ContactResult + { + return ContactResult::create([ + 'id' => $contact['infData']['id'], + 'name' => $contact['infData']['postalInfo@int']['name'] + ?? $contact['infData']['postalInfo@loc']['name'] + ?? null, + 'email' => $contact['infData']['email'], + 'phone' => $contact['infData']['voice'], + 'organisation' => $contact['infData']['postalInfo@int']['org'] + ?? $contact['infData']['postalInfo@loc']['org'] + ?? null, + 'address1' => $contact['infData']['postalInfo@int']['addr']['street'][0] + ?? $contact['infData']['postalInfo@loc']['addr']['street'][0], + 'city' => $contact['infData']['postalInfo@int']['addr']['city'] + ?? $contact['infData']['postalInfo@loc']['addr']['city'], + 'state' => Utils::stateCodeToName( + $contact['infData']['postalInfo@int']['addr']['cc'] + ?? $contact['infData']['postalInfo@loc']['addr']['cc'], + $contact['infData']['postalInfo@int']['addr']['sp'] + ?? '' + ), + 'postcode' => $contact['infData']['postalInfo@int']['addr']['pc'] + ?? $contact['infData']['postalInfo@loc']['addr']['pc'] + ?? null, + 'country_code' => $contact['infData']['postalInfo@int']['addr']['cc'] + ?? $contact['infData']['postalInfo@loc']['addr']['cc'], + ]); + } + + private function _getDomain( + string $domainName, + string $msg = 'Domain data retrieved', + bool $assertActive = true + ) { + $status = ''; + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Info\Domain(); + $infoFrame->setDomain($domainName); + +// $infoXml = $infoFrame->__toString(); +// $this->getLogger()->debug(__METHOD__, compact('client', 'infoXml', 'xmlResponse')); + + $xmlResponse = $client->request($infoFrame); + $domainData = $xmlResponse->data(); + + if (empty($domainData) || !isset($domainData['infData'])) { + $codeRes = $xmlResponse->getElementsByTagName('result')->item(0)->getAttribute('code'); + $msg = $xmlResponse->getElementsByTagName('msg')->item(0)->nodeValue; + + throw $this->errorResult( + 'Unable to obtain domain data', + ['code' => $codeRes, 'msg' => $msg, 'data' => $domainData], + ['xml' => (string)$xmlResponse] + ); + } + + $ns = []; + foreach ($domainData['infData']['ns']['hostObj'] ?? [] as $i => $nameserver) { + $ns['ns' . ($i + 1)] = [ + 'host' => $nameserver, + ]; + } + + $contacts = $this->_allContactInfo( + $domainData['infData']['registrant'], + $domainData['infData']['contact@billing'] ?? null, + $domainData['infData']['contact@admin'] ?? null, + $domainData['infData']['contact@tech'] ?? null + ); + + $statusArray = $xmlResponse->getElementsByTagName("status"); + $currentStatuses = []; + foreach ($statusArray as $nn) { + $status = $nn->getAttribute("s"); + if ($status == 'ok') { + $status = 'Active'; + } + $currentStatuses[] = $status; + } + + $lockStatus = false; + // $renewStatus = false; + + $arrSearch = array_search("clientDeleteProhibited", $currentStatuses); + if ($arrSearch !== false) { + if (array_key_exists($arrSearch, $currentStatuses) == 1 || array_key_exists(array_search("clientTransferProhibited", $currentStatuses), $currentStatuses) == 1) { + $lockStatus = true; + } + } + // $arrSearch = array_search("clientRenewProhibited", $currentStatuses); + // if ($arrSearch !== false) { + // if (array_key_exists($arrSearch, $currentStatuses) == 1) { + // $renewStatus = true; + // } + // } + + $info = DomainResult::create([ + 'id' => $domainData['infData']['roid'], + 'domain' => $domainName, + 'statuses' => $currentStatuses, + 'locked' => $lockStatus, + // 'renew' => $renewStatus, + 'registrant' => $contacts['registrant'], + 'billing' => $contacts['billing'] ?? null, + 'admin' => $contacts['administrative'] ?? null, + 'tech' => $contacts['technical'] ?? null, + 'ns' => $ns, + 'created_at' => Utils::formatDate($domainData['infData']['crDate']), + 'updated_at' => Utils::formatDate($domainData['infData']['upDate'] ?? $domainData['infData']['crDate']), + 'expires_at' => Utils::formatDate($domainData['infData']['exDate']), + ])->setMessage($msg); + +// $arrSearch = array_search("Active", $currentStatuses); +// if ($assertActive && $arrSearch === false) { +// throw $this->errorResult(sprintf('Domain name is %s', $status), $info->toArray()); +// } + + return $info; + } + + /** + * Assert the given Response frame indicates success. + * + * @throws ProvisionFunctionError + */ + private function checkResponse(Response $xmlResponse, ?string $failureMessage = null, array $data = []): void + { + /** @var \AfriCC\EPP\DOM\DomElement $result */ + $result = $xmlResponse->getElementsByTagName('result')->item(0); + $responseCode = $result->getAttribute('code'); + $responseMessage = $xmlResponse->getElementsByTagName('msg')->item(0)->nodeValue; + + $errorMessage = sprintf('%s [%s]', $failureMessage ?: 'Domain registry error', $responseCode); + + if ($responseMessage) { + $errorMessage = sprintf('%s: %s', $errorMessage, $responseMessage); + } + + if (!$this->eppSuccess($responseCode)) { + throw $this->errorResult( + $errorMessage, + array_merge(['code' => $responseCode, 'msg' => $responseMessage], $data), + ['xml' => (string)$xmlResponse] + ); + } + } + + /** + * @link https://www.rfc-editor.org/rfc/rfc5730#page-40 + * + * @param $code + * + * @return bool + */ + private function eppSuccess($code): bool + { + if ($code >= 1000 && $code < 2000) { + return true; + } + + return false; + } + + /** + * @param string $sld + * @param string $tld + * @param \Upmind\ProvisionProviders\DomainNames\Data\ContactParams $contact + * @param string $type + * @return ContactResult + * @throws \Exception + */ + private function updateContact(string $sld, string $tld, \Upmind\ProvisionProviders\DomainNames\Data\ContactParams $contact, string $type) + { + $domainName = Utils::getDomain($sld, $tld); + $domain = $this->_getDomain($domainName)->toArray(); + + if (!isset($domain['registrant']['id'])) { + throw $this->errorResult( + 'Unable to determine domain registrant', + ['domain_info' => $domain], + ); + } + $id = $domain['registrant']['id']; + + $client = $this->getClient(); + $infoFrame = new \AfriCC\EPP\Frame\Command\Update\Contact(); + $infoFrame->setId($id); + + $mode = 'chg'; + $infoFrame->appendCity('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:city', $contact->city); + $infoFrame->appendEmail('contact:chg/contact:email', $contact->email); + $infoFrame->appendCountryCode('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:cc', Utils::normalizeCountryCode($contact->country_code)); + $infoFrame->appendName('contact:chg/contact:postalInfo[@type=\'%s\']/contact:name', $contact->name); + $infoFrame->appendOrganization('contact:chg/contact:postalInfo[@type=\'%s\']/contact:org', $contact->organisation ?? $contact->name); + $infoFrame->appendPostalCode('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:pc', $contact->postcode); + $infoFrame->appendProvince('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:sp', Utils::stateNameToCode($contact->country_code, $contact->state)); + $infoFrame->appendVoice('contact:chg/contact:voice', Utils::internationalPhoneToEpp($contact->phone)); + $infoFrame->appendStreet('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:street[]', $contact->address1); + +// $infoFrame->setCity($mode, $contact->city); +// $infoFrame->setName($mode, $contact->name); +// $infoFrame->setCountryCode($mode, Utils::normalizeCountryCode($contact->country_code)); +// $infoFrame->setEmail($mode, $contact->email); +// $infoFrame->setOrganization($mode, $contact->organisation?? $contact->name); +// $infoFrame->setPostalCode($mode, $contact->postcode); +// $infoFrame->addStreet($mode, $contact->address1); +// $infoFrame->setVoice($mode, Utils::internationalPhoneToEpp($contact->phone)); +// $infoFrame->setProvince($mode, Utils::stateNameToCode($contact->country_code, $contact->state)); + + $xmlResponse = $client->request($infoFrame); + $this->checkResponse($xmlResponse); + + return ContactResult::create([ + 'contact_id' => $id, + 'name' => $contact->name, + 'email' => $contact->email, + 'phone' => $contact->phone, + 'organisation' => $contact->organisation, + 'address1' => $contact->address1, + 'city' => $contact->city, + 'postcode' => $contact->postcode, + 'country_code' => $contact->country_code, + 'state' => Utils::stateNameToCode($contact->country_code, $contact->state), + ])->setMessage('Contact details updated'); + } + + private function addNameserverHost(string $nameserver): void + { + if (!$this->checkNameserverExists($nameserver)) { + $createFrame = new \AfriCC\EPP\Frame\Command\Create\Host(); + $createFrame->setHost($nameserver); + + $xmlResponse = $this->getClient()->request($createFrame); + $this->checkResponse($xmlResponse, 'Failed to create nameserver', ['nameserver' => $nameserver]); + } + } + + /** + * @param string $nameServer + * @return bool + * @throws \Exception + */ + private function checkNameserverExists(string $nameServer): bool + { + $client = $this->getClient(); + + $infoFrame = new \AfriCC\EPP\Frame\Command\Info\Host(); + + $infoFrame->setHost($nameServer); + $xmlResponse = $client->request($infoFrame); + + $codeRes = (string)$xmlResponse->getElementsByTagName('result')->item(0)->getAttribute('code'); + return $this->eppSuccess($codeRes) + || $codeRes === '2302' /** "Object exists" @link https://www.rfc-editor.org/rfc/rfc5730#page-43 */; + } + + /** + * @param ContactParams $contact + * @return string Contact id + * @throws \Propaganistas\LaravelPhone\Exceptions\NumberParseException + */ + private function createContact(ContactParams $contact): string + { + $client = $this->getClient(); + $infoFrame = new \AfriCC\EPP\Frame\Command\Create\Contact(); + $id = $this->generateHandle(); + $infoFrame->setId($id); + +// $mode = 'create'; +// $infoFrame->appendCity('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:city', $contact->city); +// $infoFrame->appendEmail('contact:chg/contact:email', $contact->email); +// $infoFrame->appendCountryCode('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:cc', Utils::normalizeCountryCode($contact->country_code)); +// $infoFrame->appendName('contact:chg/contact:postalInfo[@type=\'%s\']/contact:name', $contact->name); +// $infoFrame->appendOrganization('contact:chg/contact:postalInfo[@type=\'%s\']/contact:org', $contact->organisation?? $contact->name); +// $infoFrame->appendPostalCode('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:pc', $contact->postcode); +// $infoFrame->appendProvince('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:sp', Utils::stateNameToCode($contact->country_code, $contact->state)); +// $infoFrame->appendVoice('contact:chg/contact:voice', Utils::internationalPhoneToEpp($contact->phone)); +// $infoFrame->appendStreet('contact:chg/contact:postalInfo[@type=\'%s\']/contact:addr/contact:street[]', $contact->address1); + + $infoFrame->setCity($contact->city); + $infoFrame->setName($contact->name ?? $contact->organisation); + $infoFrame->setCountryCode(Utils::normalizeCountryCode($contact->country_code)); + $infoFrame->setEmail($contact->email); + $infoFrame->setOrganization($contact->organisation ?? $contact->name); + $infoFrame->setPostalCode($contact->postcode); + $infoFrame->addStreet($contact->address1); + $infoFrame->setVoice(Utils::internationalPhoneToEpp($contact->phone)); + $infoFrame->setProvince(Utils::stateNameToCode($contact->country_code, $contact->state)); + + $xmlResponse = $client->request($infoFrame); + $this->checkResponse($xmlResponse); + + return $id; + } + + /** + * @return string + */ + public function generateHandle() + { + $stamp = time(); + $shuffled = str_shuffle("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"); + $randStr = substr($shuffled, mt_rand(0, 45), 5); + $handle = "$stamp$randStr"; + return $handle; + } + + /** + * @param string $type + * @return string|null + */ + private function mapType(string $type): ?string + { + switch ($type) { + case 'Domain Transferred Away': + case 'Domain transfer approved on your behalf.': + return DomainNotification::TYPE_TRANSFER_OUT; + case 'Domain deleted': + return DomainNotification::TYPE_DELETED; + } + return null; + } +} diff --git a/src/CoccaEpp/default_cert.pem b/src/CoccaEpp/default_cert.pem new file mode 100644 index 0000000..9084fde --- /dev/null +++ b/src/CoccaEpp/default_cert.pem @@ -0,0 +1,58 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqMcejxRrpO +c2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4PiUifsz0 +Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi8eABg97A +0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs/pQPEX6q +IC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XDx+KUnv+D +6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABAoIBADaynUAqyjH2LGMB +mKbe133Zg0tSgFuOWaBuOnUHQkMzjuLOMX3VrBVBBRQrD93FEQNvYbud/LWk5RmK +yHafA2RrA43R1diX3NUqJhErTmMSwZuoy6d9zZlLH8IpqG/3tj0Ztghx6BVGN0GQ +v40IJ0zFeq5X5TZyq1tnTAFld18W/E+4L5DmAn4ROoIscPh/u7ua/wrD1D4ooO5h +NWMXvEuA4o60TGeQCjzssEVzwgUVPKQBqhhB2kQrvpNqCKV/cLuBdqGb2VX9+hXm +zzIkGIkDSQ5+NhcfnBHVirsH3KNKX/m8z2GiBttuIN/OQqM84tN3kKTDe4aN0f9i +7lidZaECgYEA51HsCKTBOh9OZTu5lT3Ev1dcerSIKSlDk80TnmBBsNr8ctIQSID5 +28ARU1L+CH7+06XIEbcBThCHrJNMBA+PE/Ykqw64zzXk7ik1tCly/hFSVKpZD6Wp +lEjlYESCqL8UKg9MoVjc8cHnLf4smG2X+SQMDaqneIJZy8neuhsS4bUCgYEA2mvX +eHYQNUit05hweUR7VtZxuSLSOvyHvAxrEdG22eYMLk6LF9y9IC2yWpz/cdWHMqLq +3unA42OS4/tSqxoSKYBSdoyFGWX5NltFCzZPXd78D8FVz8U626X28L21bcgVcwCd +EJ8aU+zhzqixSkC9dxDX6HJcxfbaVbucNoSca9kCgYEAwHSaSr64vSDa2sMMLq0L +ip6mpLibKJPaU5gmIHi5buljbCx1u70DJN/yCj9cd7khTvn5MTPvdAGwv9Z1QlOn +mNYLv/4pqMyQQc4rjk+GCvhiZWqtWqVcJ7FWlfeqNbd0kWHVQdBrUwEe1FdKxy83 +Z+Oj26MGXu8kwracBn8MAJkCgYAIHmIP9DN+B4mOh+gGWelLvQTVINo3nxNchgmk +y+rEBq0FO54n8OiGvawXeiZ0kL9JvoyEZKPqz9Sx7LGR8pIiQMbP6UE5RHUS9CmI +1Sf2EUfFPiZ2Zppdd7nKEQMhZYKGl8s+xusvm2p5SAPvAqEIP/QGi9mu8hIDhcm0 +rREzYQKBgDfe4Z+bVD13ASXtJ+C7gOmaIQTQI/68DIT2eFR6IOc+LM1K1o8u/KQy +NNWaOIiVe7oXWOg59HmMH7bkra/I/qBb4Fzb4QOhLxXlMthTmG2dJBrQJMLoirHX +BIWiYoeRv3V+H6p4oGP+g4LAfYleEcAUij6YQLy9oUwbusZlFeWs +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFZjCCBE6gAwIBAgIQK+MtAo2IygRd0TCN2bO5QDANBgkqhkiG9w0BAQsFADCB +kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV +BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD +QTAeFw0xNjA5MjIwMDAwMDBaFw0xNzA5MjIyMzU5NTlaMF4xITAfBgNVBAsTGERv +bWFpbiBDb250cm9sIFZhbGlkYXRlZDEhMB8GA1UECxMYUG9zaXRpdmVTU0wgTXVs +dGktRG9tYWluMRYwFAYDVQQDEw1nYXJhbm50b3IuY29tMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqM +cejxRrpOc2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4 +PiUifsz0Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi +8eABg97A0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs +/pQPEX6qIC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XD +x+KUnv+D6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABo4IB6zCCAecw +HwYDVR0jBBgwFoAUkK9qOpRaC9iQ6hJWc99DtDoo2ucwHQYDVR0OBBYEFEk8baOc +uKUuA4ORiEI3AXdZGXBYMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0G +A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBPBgNVHSAESDBGMDoGCysGAQQB +sjEBAgIHMCswKQYIKwYBBQUHAgEWHWh0dHBzOi8vc2VjdXJlLmNvbW9kby5jb20v +Q1BTMAgGBmeBDAECATBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLmNvbW9k +b2NhLmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0Eu +Y3JsMIGFBggrBgEFBQcBAQR5MHcwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuY29t +b2RvY2EuY29tL0NPTU9ET1JTQURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJD +QS5jcnQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTA5BgNV +HREEMjAwgg1nYXJhbm50b3IuY29tgg8qLmdhcmFubnRvci5jb22CDiouZ2FyYW5u +dG9yLm5nMA0GCSqGSIb3DQEBCwUAA4IBAQBcvhvND3DnYZEytIWIDy/LbrkfzVOQ +GvGxsMQsNYLmYmsmnYLA4cBlZ2DIZbWehfMPXAbZgre2+Y8yOFZOzRR848hU1Kka +7Tf/XUMr/YEV+SwRiEjL3cX1HLXPXzB0DhAG/3rsK+ANePxWNCXLK5riBKSN1C45 +1cahmHRw4+DNccUTZAX6yOhWmNtgvYwbtAqxZfkIivaB4J7nuolG5d/FHMOpEqwL +TEBWT0I5fuGX2XO94Xw9zyEjhPF0Psiqzi+6Umv8lyelLr/NI3b3g2kfcvoM6KRe +6K3NgNT8eJGH4Tf2OM4AGd20FwqWwlVe86Nc9qYJQsGmMwzU2sfem+AQ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/ConnectReseller/Data/ConnectResellerConfiguration.php b/src/ConnectReseller/Data/ConnectResellerConfiguration.php new file mode 100644 index 0000000..7ec8900 --- /dev/null +++ b/src/ConnectReseller/Data/ConnectResellerConfiguration.php @@ -0,0 +1,28 @@ + ['required', 'string'], + 'sandbox' => ['boolean'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/ConnectReseller/Provider.php b/src/ConnectReseller/Provider.php new file mode 100644 index 0000000..701366f --- /dev/null +++ b/src/ConnectReseller/Provider.php @@ -0,0 +1,861 @@ +configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('ConnectReseller') + ->setDescription('Register, transfer, renew and manage ConnectReseller domains') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/connectreseller-logo_2x.png'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $data = []; + $domainName = Utils::getDomain($params->sld, $params->tld); + + $customerId = $this->_getCustomerId($params->registrant->register); + + $data['Id'] = $customerId; + $data['Duration'] = $params->renew_years; + $data['IsWhoisProtection'] = true; + $data['Websitename'] = $domainName; + $data['ProductType'] = self::PRODUCT_TYPE_REGISTER; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + $ns = 'ns' . $i; + + if (isset($params->nameservers->$ns->host)) { + $data[$ns] = $params->nameservers->$ns->host; + } + } + + if (empty($data['ns1']) || empty($data['ns2'])) { + $data['ns1'] = $this->_getDefaultNameserver(1); + $data['ns2'] = $this->_getDefaultNameserver(2); + } + + $this->_callApi($data, 'Order'); + + $registrantContactId = $this->_createContact($params->registrant->register, $customerId); + $billingContactId = $this->_createContact($params->billing->register); + $techContactId = $this->_createContact($params->tech->register); + $adminContactId = $this->_createContact($params->admin->register); + + $this->_setContacts( + $domainName, + $registrantContactId, + $billingContactId, + $techContactId, + $adminContactId, + ); + + return $this->_getDomain($domainName)->setMessage('Domain registered - ' . $domainName); + } + + public function transfer(TransferParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + try { + return $this->_getDomain($domainName)->setMessage('Domain active in registrar account'); + } catch (Throwable $e) { + // domain not active - continue below + } + + try { + $this->_checkTransfer($domainName); + } catch (InvalidArgumentException $e) { + // we need to initiate a transfer order + } + + $eppCode = $params->epp_code ?? '0000'; + $customerId = $this->_getCustomerId($params->admin->register); + + $transferResponse = $this->_initiateTransfer($customerId, $domainName, $eppCode); + + throw $this->errorResult('Domain transfer initiated', [], ['response_data' => $transferResponse]); + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $expiryDate = $this->_renewDomain($domain, $params->renew_years); + + return $this->_getDomain($domain) + ->setMessage('Domain successfully renewed') + ->setExpiresAt(Carbon::parse($expiryDate)); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + return $this->_getDomain($domainName)->setMessage('Domain info obtained'); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + $domainId = $this->_getDomainId($domain); + + $paramsApi = [ + 'domainNameId' => $domainId, + 'websiteName' => $domain, + ]; + + $paramsApi['nameServer1'] = $params->ns1->host; + $paramsApi['nameServer2'] = $params->ns2->host; + if (isset($params->ns3->host)) { + $paramsApi['nameServer3'] = $params->ns3->host; + } + if (isset($params->ns4->host)) { + $paramsApi['nameServer4'] = $params->ns4->host; + } + + $this->_callApi($paramsApi, 'UpdateNameServer'); + + $returnNameservers = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (isset($paramsApi['nameServer' . $i])) { + $returnNameservers['ns' . $i] = [ + 'host' => $paramsApi['nameServer' . $i], + 'ip' => Arr::get($params, 'ns' . $i)['ip'] + ]; + } + } + + return NameserversResult::create($returnNameservers) + ->setMessage('Nameservers updated'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + $domainData = $this->_getDomain(Utils::getDomain($params->sld, $params->tld)); + + $eppCode = $domainData['responseData']['authCode'] ?? null; + + if (empty($eppCode)) { + $eppCode = $this->_callApi([ + 'domainNameId' => $domainData['id'], + ], 'ViewEPPCode')['responseData']; + } + + return EppCodeResult::create([ + 'epp_code' => $eppCode + ]); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported!'); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $registrantContactId = $this->_createContact($params->contact, $this->_getDomainCustomerId($domain)); + + $domainInfo = $this->_getDomain($domain); + + $this->_setContacts( + $domain, + $registrantContactId, + $domainInfo->billing->id, + $domainInfo->tech->id, + $domainInfo->admin->id + ); + + return ContactResult::create($this->_getContactData($registrantContactId)->toArray()) + ->setMessage('Registrant contact updated'); + } + + public function setLock(LockParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + $domainId = $this->_getDomainId($domain); + + $domainData = $this->_getDomain($domain); + + if ($domainData->locked == $params->lock) { + return $domainData->setLocked($params->lock) + ->setMessage(sprintf('Domain already %s', $domainData->locked ? 'locked' : 'unlocked')); + } + + $this->_callApi([ + 'domainNameId' => $domainId, + 'websiteName' => $domain, + 'isDomainLocked' => $params->lock, + ], 'ManageDomainLock'); + + return $domainData->setLocked($params->lock) + ->setMessage(sprintf('Domain %s', $params->lock ? 'locked' : 'unlocked')); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('The requested operation not supported', $params); + } + + /** + * @throws ProvisionFunctionError If domain doesn't exist or is otherwise not active + */ + protected function _getDomain(string $domainName, $assertActive = true): DomainResult + { + $domainDataCall = $this->_callApi( + [ + 'websiteName' => $domainName + ], + 'ViewDomain', + 'GET' + ); + + $domainData = $domainDataCall['responseData']; + + $ns = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (isset($domainData['nameserver' . $i])) { + $ns['ns' . $i] = [ + 'host' => $domainData['nameserver' . $i], + ]; + } + } + + $statuses = []; + if (isset($domainData['status']) && $domainData['status']) { + $statuses = [$domainData['status']]; + } + + $info = DomainResult::create([ + 'id' => (string)$domainData['domainNameId'], + 'domain' => $domainData['websiteName'], + 'statuses' => $statuses, + 'locked' => $domainData['isDomainLocked'], + 'registrant' => $this->_getContactData($domainData['registrantContactId']), + 'billing' => ['id' => $domainData['billingContactId']], + 'admin' => ['id' => $domainData['adminContactId']], + 'tech' => ['id' => $domainData['technicalContactId']], + 'ns' => $ns, + 'created_at' => $this->_timestampToDateTime($domainData['creationDate']), + 'updated_at' => $this->_timestampToDateTime($domainData['lastUpdatedDate'] ?? $domainData['creationDate']), + 'expires_at' => $this->_timestampToDateTime($domainData['expirationDate']), + ]); + + if ($assertActive && !array_intersect(['Active', 'Locked'], $statuses)) { + $message = 'Domain is not active'; + + throw $this->errorResult( + $message, + $info->toArray(), + ['response_data' => $domainData] + ); + } + + return $info; + } + + /** + * @param string $domain + * @param int|string $registrantContactId + * @param int|string $billingContactId + * @param int|string $techContactId + * @param int|string $adminContactId + * @param int|null $domainId + */ + protected function _setContacts( + string $domain, + $registrantContactId, + $billingContactId, + $techContactId, + $adminContactId, + $domainId = null + ) { + try { + $this->_callApi([ + 'domainNameId' => $domainId ?? $this->_getDomainId($domain), + 'websiteName' => $domain, + 'registrantContactId' => $this->_normalizeContactId($registrantContactId), + 'billingContactId' => $this->_normalizeContactId($billingContactId), + 'technicalContactId' => $this->_normalizeContactId($techContactId), + 'adminContactId' => $this->_normalizeContactId($adminContactId), + ], 'updatecontact'); + } catch (ProvisionFunctionError $e) { + if (1000 === Arr::get($e->getDebug(), 'response_data.responseData.statusCode')) { + // unbelievably, they return an error code for this in responseMsg but it's not an error! + return; + } + + throw $e; + } + } + + protected function _getDomainId(string $domain): int + { + return $this->_callApi([ + 'websiteName' => $domain + ], 'ViewDomain')['responseData']['domainNameId']; + } + + protected function _getDomainCustomerId(string $domain): int + { + return $this->_callApi([ + 'websiteName' => $domain + ], 'ViewDomain')['responseData']['customerId']; + } + + /** + * Renew the given domain name and obtain the new expiry date. + * + * @param string $domainName + * @param int $renewYears + * + * @return string New expiry date + */ + protected function _renewDomain(string $domainName, int $renewYears): string + { + $data = $this->_callApi([ + 'Id' => $this->_getDomainCustomerId($domainName), + 'Websitename' => $domainName, + 'OrderType' => self::ORDER_TYPE_RENEW, + 'Duration' => $renewYears + ], 'RenewalOrder'); + + return $data['responseData']['exdate']; + } + + /** + * @param array $data + * @param string $path + * @param string $method + * @return mixed + * @throws Exception + */ + protected function _callApi(array $data, string $path, string $method = 'GET') + { + $url = 'https://api.connectreseller.com/ConnectReseller/ESHOP/'; + $url .= $path ; + + $client = new Client([ + 'handler' => $this->getGuzzleHandlerStack(boolval($this->configuration->debug)), + 'headers' => [ + 'Accept' => 'application/json', + ], + ]); + + $query = array_merge( + $data, + ['APIKey' => $this->configuration->api_key] + ); + + try { + $response = $client->request( + $method, + $url, + ['query' => $query] + ); + + $responseData = $this->getResponseData($response); + + $statusCode = $responseData['responseMsg']['statusCode'] + ?? $responseData['responseData']['statusCode'] + ?? $responseData['statusCode'] + ?? 'unknown'; + if (!in_array($statusCode, [200, 1000])) { + $errorMessage = $this->getResponseErrorMessage($responseData); + + throw $this->errorResult( + sprintf('Provider API %s error: %s', $statusCode, $errorMessage), + [], + ['response_data' => $responseData], + ); + } + + return $responseData; + } catch (Throwable $e) { + $this->handleException($e); + + throw $e; + } + } + + /** + * @throws ProvisionFunctionError + * @throws Throwable If error is completely unexpected + * + * @return no-return + */ + protected function handleException(Throwable $e): void + { + if ($e instanceof RequestException) { + if ($e->hasResponse()) { + $response = $e->getResponse(); + + // application/json responses + $responseData = $this->getResponseData($response); + $errorMessage = $this->getResponseErrorMessage($responseData); + + throw $this->errorResult( + sprintf('Provider API error: %s', $errorMessage), + [], + ['response_data' => $responseData], + $e + ); + } + } + + // totally unexpected error - re-throw and let provision system handle + throw $e; + } + + /** + * Obtain the response body data from the given api response. + * + * @return array|string|int + */ + protected function getResponseData(Response $response) + { + $body = trim($response->getBody()->__toString()); + + return json_decode($body, true); + } + + /** + * Get a friendly error message from the given response data. + * + * @param array $responseData + * + * @return string + */ + protected function getResponseErrorMessage($responseData): string + { + $statusCode = $responseData['responseMsg']['statusCode'] ?? $responseData['statusCode'] ?? 'unknown'; + + $errorMessage = trim( + $responseData['responseText'] ?? $responseData['error'] ?? 'unknown error' + ); + + if (isset($responseData['statusText'])) { + $errorMessage = ucwords(str_replace('_', ' ', Str::snake($responseData['statusText']))); + } + + if (isset($responseData['responseText'])) { + $errorMessage = ucwords($responseData['responseText']); + } + + if (isset($responseData['responseMsg']['message'])) { + $errorMessage = $responseData['responseMsg']['message']; + } + + // Unauthorized error thrown when an un-whitelisted IP is used + if ($statusCode === 401 && ($responseData['statusText'] ?? null) === 'Unauthorized') { + $errorMessage = 'Authentication failed - please review whitelisted IPs'; + } + + return $errorMessage; + } + + protected function _getCustomerId(ContactParams $contact) + { + $email = $this->_normalizeEmail($contact->email); + + if (isset($this->customerIds[$email])) { + return $this->customerIds[$email]; + } + + $customerData = $this->_getCustomer($email); + + if (isset($customerData['responseData']['clientId'])) { + return $customerData['responseData']['clientId']; + } + + return $this->customerIds[$email] = $this->_createCustomer($contact); + } + + /** + * Create a contact and return the contact id. + * + * @return int + */ + protected function _createContact(ContactParams $contact, ?int $customerId = null): int + { + if ($contact->phone) { + [$phoneCode, $phone] = $this->getPhoneParts($contact->phone); + } + + $data = [ + 'Id' => $customerId ?? $this->_getCustomerId($contact), + 'EmailAddress' => $this->_normalizeEmail($contact->email), + 'Password' => $this->_generateRandomPassword(), + 'Name' => $contact->name ?? $contact->organisation, + 'CompanyName' => $contact->organisation, + 'Address' => $contact->address1, + 'City' => $contact->city, + 'StateName' => $contact->state ?? $contact->city, + 'CountryName' => $this->_countryCodeToName($contact->country_code), + 'Zip' => $contact->postcode, + 'PhoneNo_cc' => $phoneCode ?? null, + 'PhoneNo' => $phone ?? null, + ]; + + return $this->_callApi($data, 'AddRegistrantContact')['responseMsg']['id']; + } + + protected function _getCustomer(string $email): array + { + $data = [ + 'UserName' => $email + ]; + try { + return $this->_callApi($data, 'ViewClient', 'GET'); + } catch (Exception $e) { + return []; + } + } + + /** + * Create a customer and return its ID. + */ + protected function _createCustomer(ContactParams $contact): int + { + if ($contact->phone) { + [$phoneCode, $phone] = $this->getPhoneParts($contact->phone); + } + + $data = [ + 'FirstName' => $contact->name ?? $contact->organisation, + 'UserName' => $this->_normalizeEmail($contact->email), + 'Password' => $this->_generateRandomPassword(), + 'CompanyName' => $contact->organisation, + 'Address1' => $contact->address1, + 'City' => $contact->city, + 'StateName' => $contact->state ?? $contact->city, + 'CountryName' => $this->_countryCodeToName($contact->country_code), + 'Zip' => $contact->postcode, + 'PhoneNo_cc' => $phoneCode ?? null, + 'PhoneNo' => $phone ?? null, + ]; + + return $this->_callApi($data, 'AddClient')['responseData']['clientId']; + } + + private function _generateRandomPassword() + { + return bin2hex(openssl_random_pseudo_bytes(4)); + } + + private function _getDefaultNameserver(int $int) + { + $serverName = 'connectreseller.com'; + + return 'ns' . $int . '.' . $serverName; + } + + private function _initiateTransfer(int $customerId, string $domainName, $eppCode): array + { + $transferData = $this->_callApi([ + 'Websitename' => $domainName, + 'AuthCode' => $eppCode, + 'Authcode' => $eppCode, // API docs inconsistent about the precise parameter name + 'OrderType' => self::ORDER_TYPE_TRANSFER, + 'Id' => $customerId, + 'IsWhoisProtection' => true + ], 'TransferOrder'); + + if ($transferData['responseData']['statusCode'] !== 200) { + // transfer initiation failed somewhat + $message = $transferData['responseData']['message']; + + if (Str::contains($message, 'invalid AuthCode')) { + // epp code invalid + throw $this->errorResult('Invalid EPP Code', [], ['response_data' => $transferData]); + } + + throw $this->errorResult( + sprintf('Provider API %s error: %s', $transferData['responseData']['statusCode'], $message), + [], + ['response_data' => $transferData], + ); + } + + return $transferData; + } + + /** + * Check the status of an existing transfer order and return successful + * transfer data. + * + * @throws InvalidArgumentException If transfer order doesn't exist or EPP code is invalid (initiate new transfer) + * @throws ProvisionFunctionError If transfer is in progress and we just gotta wait + * + * @return array Successful transfer data, if transfer is complete + */ + protected function _checkTransfer(string $domain) + { + try { + $checkData = $this->_callApi([ + 'domainName' => $domain, + ], 'syncTransfer'); + + $status = Arr::get($checkData, 'responseData.status'); + $reason = Arr::get($checkData, 'responseData.reason'); + $expiryDate = Arr::get($checkData, 'responseData.expiryDate'); + + if ($status === 'pending') { + // transfer in progress + if (Str::contains($reason, 'Invalid Auth Code')) { + // actually, transfer NOT in progress cause epp code was invalid ...! + throw new InvalidArgumentException('Transfer was initiated with an invalid auth code'); + } + } + + if (empty($expiryDate)) { + throw $this->errorResult(sprintf('Transfer %s: %s', $status, $reason), [], ['response_data' => $checkData]); + } + + return $checkData; + } catch (ProvisionFunctionError $e) { + $checkData = Arr::get($e->getDebug(), 'response_data') ?: []; + + if (!Arr::has($checkData, 'responseData.status')) { + // E.g., API returned a 500 error (super helpful response!) + // maybe domain doesn't exist at all? + throw new InvalidArgumentException('Transfer status uncheckable for this domain', 0, $e); + } + + $status = Arr::get($checkData, 'responseData.status'); + + if ($status === 'failed') { + // E.g., Given domain was registered instead of transferred (super helpful response!) + // maybe transfer order never existed? + throw new InvalidArgumentException('Unable to check transfer status of this domain', 0, $e); + } + + throw $e; + } + } + + protected function _getContactData($contactId): ContactData + { + $data = $this->_callApi([ + 'RegistrantContactId' => $this->_normalizeContactId($contactId), + ], 'ViewRegistrant'); + + $contact = $data['responseData']; + + return ContactData::create([ + 'id' => $contactId, + 'name' => $contact['name'], + 'email' => $contact['emailAddress'], + 'phone' => '+' . $contact['phoneCode'] . $contact['phoneNo'], + 'organisation' => $contact['companyName'], + 'address1' => $contact['address1'], + 'city' => $contact['city'], + 'state' => $contact['stateName'], + 'postcode' => $contact['zipCode'], + 'country_code' => $this->_countryNameToCode($contact['countryName']), + ]); + } + + /** + * Obtain the given contact id without a OR_ prefix. + * + * @param string|int $contactId + * + * @return string + */ + private function _normalizeContactId($contactId): string + { + $pieces = explode('_', (string)$contactId, 2); + + return array_pop($pieces); + } + + /** + * Get the international dialling code and local number as a 2-tuple. + * + * @param string $phone International phone number + * + * @return string[] E.g., ['44', '1234567890'] + */ + protected function getPhoneParts($phone): array + { + if (empty($phone)) { + return [null, null]; + } + + $eppPhone = Utils::internationalPhoneToEpp($phone); + + return explode('.', Str::replaceFirst('+', '', $eppPhone), 2); + } + + /** + * Removes the local part from an email address if present, to make it + * compatible with ConnectReseller. + * + * @param string $email E.g., harry+test@upmind.com + * + * @return string E.g., harry@upmind.com + */ + protected function _normalizeEmail(string $email): string + { + if (Str::contains($email, '+')) { + $parts = explode('@', $email, 2); + $parts[0] = preg_replace('/\+.+/', '', $parts[0]); + + $email = implode('@', $parts); + } + + return $email; + } + + /** + * Obtain a ConnectReseller compatible country name for the given alpha-2 + * ISO country code. + * + * @param string $countryCode E.g., GB + * + * @return string E.g., United Kingdom + */ + protected function _countryCodeToName(string $countryCode): ?string + { + $countryCode = Countries::normalizeCode($countryCode); + + switch ($countryCode) { + case 'GB': + return 'United Kingdom'; // woof!! + default: + return Countries::codeToName($countryCode); + } + } + + /** + * Obtain an alpha-2 ISO country code from the given ConnectReseller country + * name. + * + * @param string $country E.g., United Kingdom + * + * @return string E.g., GB + */ + protected function _countryNameToCode(string $country): ?string + { + switch ($country) { + case 'United Kingdom': + return 'GB'; // woof!! + default: + return Countries::nameToCode($country); + } + } + + /** + * Converts a ConnectReseller timestamp from weird unix with trailing zeroes + * to ISO-8601 format. + * + * @param int|string $timestamp E.g., 1683761336000 + * + * @return string E.g., 2023-05-10 23:28:56 + */ + protected function _timestampToDateTime($timestamp): string + { + $timestamp = strval($timestamp); + + // for some reason timestamps come back with trailing 0s ?! + if (strlen($timestamp) > 10 && Str::endsWith($timestamp, '0')) { + $timestamp = rtrim($timestamp, '0'); + } + + return Carbon::parse(intval($timestamp))->format('Y-m-d H:i:s'); + } +} diff --git a/src/Data/AutoRenewParams.php b/src/Data/AutoRenewParams.php new file mode 100644 index 0000000..4f3618f --- /dev/null +++ b/src/Data/AutoRenewParams.php @@ -0,0 +1,27 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'auto_renew' => ['required', 'boolean'], + ]); + } +} diff --git a/src/Data/ContactData.php b/src/Data/ContactData.php new file mode 100644 index 0000000..911c24b --- /dev/null +++ b/src/Data/ContactData.php @@ -0,0 +1,41 @@ + ['nullable'], + 'name' => ['nullable', 'string'], + 'organisation' => ['nullable', 'string'], + 'email' => ['nullable', 'string'], + 'phone' => ['nullable', 'string'], + 'address1' => ['nullable', 'string'], + 'city' => ['nullable', 'string'], + 'state' => ['nullable', 'string'], + 'postcode' => ['nullable', 'string'], + 'country_code' => ['nullable', 'string', 'size:2', 'country_code'], + ]); + } +} diff --git a/src/Data/ContactParams.php b/src/Data/ContactParams.php new file mode 100644 index 0000000..cada17b --- /dev/null +++ b/src/Data/ContactParams.php @@ -0,0 +1,43 @@ + ['required_without:organisation', 'string'], + 'organisation' => ['required_without:name', 'string'], + 'email' => ['required', 'email'], + 'phone' => ['required', 'string', 'international_phone'], + 'address1' => ['required', 'string'], + 'city' => ['required', 'string'], + 'state' => ['nullable', 'string'], + 'postcode' => ['required', 'string'], + 'country_code' => ['required', 'string', 'size:2', 'country_code'], + 'type' => ['nullable', 'string'], + 'password' => ['nullable', 'string'], + ]); + } +} diff --git a/src/Data/ContactResult.php b/src/Data/ContactResult.php new file mode 100644 index 0000000..fee6e3c --- /dev/null +++ b/src/Data/ContactResult.php @@ -0,0 +1,45 @@ + ['required_without:organisation', 'string'], + 'organisation' => ['required_without:name', 'string'], + 'email' => ['required', 'email'], + 'phone' => ['nullable', 'string', 'international_phone'], + 'address1' => ['required', 'string'], + 'city' => ['required', 'string'], + 'state' => ['nullable', 'string'], + 'postcode' => ['nullable', 'string'], + 'country_code' => ['required', 'string', 'size:2', 'country_code'], + 'type' => ['nullable', 'string'], + 'password' => ['nullable', 'string'], + 'id' => ['nullable'], + ]); + } +} diff --git a/src/Data/DacDomain.php b/src/Data/DacDomain.php new file mode 100644 index 0000000..4c57f66 --- /dev/null +++ b/src/Data/DacDomain.php @@ -0,0 +1,88 @@ + ['required', 'domain_name'], + 'tld' => ['required', 'alpha-dash-dot'], + 'can_register' => ['required', 'boolean'], + 'can_transfer' => ['required', 'boolean'], + 'is_premium' => ['required', 'boolean'], + 'description' => ['required', 'string'], + ]); + } + + /** + * @return DacDomain $this + */ + public function setDomain(string $domain): self + { + $this->setValue('domain', $domain); + return $this; + } + + /** + * @return DacDomain $this + */ + public function setTld(string $tld): self + { + $this->setValue('tld', $tld); + return $this; + } + + /** + * @return DacDomain $this + */ + public function setCanRegister(bool $canRegister): self + { + $this->setValue('can_register', $canRegister); + return $this; + } + + /** + * @return DacDomain $this + */ + public function setCanTransfer(bool $canTransfer): self + { + $this->setValue('can_transfer', $canTransfer); + return $this; + } + + /** + * @return DacDomain $this + */ + public function setIsPremium(bool $isPremium): self + { + $this->setValue('is_premium', $isPremium); + return $this; + } + + /** + * @return DacDomain $this + */ + public function setDescription(string $description): self + { + $this->setValue('description', $description); + return $this; + } +} diff --git a/src/Data/DacParams.php b/src/Data/DacParams.php new file mode 100644 index 0000000..14c3d5c --- /dev/null +++ b/src/Data/DacParams.php @@ -0,0 +1,24 @@ + ['required', 'alpha-dash'], + 'tlds' => ['required', 'array'], + 'tlds.*' => ['required', 'alpha-dash-dot'], + ]); + } +} diff --git a/src/Data/DacResult.php b/src/Data/DacResult.php new file mode 100644 index 0000000..818dc33 --- /dev/null +++ b/src/Data/DacResult.php @@ -0,0 +1,25 @@ + ['present', 'array'], + 'domains.*' => [DacDomain::class], + ]); + } +} diff --git a/src/Data/DomainContactInfo.php b/src/Data/DomainContactInfo.php new file mode 100644 index 0000000..5e7280e --- /dev/null +++ b/src/Data/DomainContactInfo.php @@ -0,0 +1,43 @@ + ['required', 'string'], + 'name' => ['nullable', 'string'], + 'email' => ['nullable', 'string'], + 'phone' => ['nullable', 'string'], + 'organisation' => ['nullable', 'string'], + 'address1' => ['nullable', 'string'], + 'city' => ['nullable', 'string'], + 'state' => ['nullable', 'string'], + 'postcode' => ['nullable', 'string'], + 'country_code' => ['nullable', 'string'], + 'type' => ['nullable', 'string'], + ]); + } +} diff --git a/src/Data/DomainInfoParams.php b/src/Data/DomainInfoParams.php new file mode 100644 index 0000000..37e49a5 --- /dev/null +++ b/src/Data/DomainInfoParams.php @@ -0,0 +1,25 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + ]); + } +} diff --git a/src/Data/DomainNotification.php b/src/Data/DomainNotification.php new file mode 100644 index 0000000..c9451d9 --- /dev/null +++ b/src/Data/DomainNotification.php @@ -0,0 +1,129 @@ + ['required'], + 'type' => ['required', 'in:' . implode(',', self::VALID_TYPES)], + 'message' => ['required', 'string'], + 'domains' => ['required', 'array'], + 'domains.*' => ['required', 'domain_name'], + 'created_at' => ['required', 'date_format:Y-m-d H:i:s'], + 'extra' => ['array'], + ]); + } + + /** + * @var string[] + */ + public const VALID_TYPES = [ + self::TYPE_DATA_QUALITY, + self::TYPE_TRANSFER_IN, + self::TYPE_TRANSFER_OUT, + self::TYPE_RENEWED, + self::TYPE_SUSPENDED, + self::TYPE_DELETED, + ]; + + /** + * Domain registrant data requires verification. + * + * @var string + */ + public const TYPE_DATA_QUALITY = 'data_quality'; + + /** + * Domain successfully transferred in from another registrar. + * + * @var string + */ + public const TYPE_TRANSFER_IN = 'transfer_in'; + + /** + * Domain successfully transferred out / released to another registrar. + * + * @var string + */ + public const TYPE_TRANSFER_OUT = 'transfer_out'; + + /** + * Domain successfully renewed. + * + * @var string + */ + public const TYPE_RENEWED = 'renewed'; + + /** + * Domain suspended e.g., after enough time having elapsed following an unresolved DQ issue. + * + * @var string + */ + public const TYPE_SUSPENDED = 'suspended'; + + /** + * Domain deleted e.g., after enough time having elapsed following expiry. + * + * @var string + */ + public const TYPE_DELETED = 'deleted'; + + /** + * @param int|string $messageId + */ + public function setId($messageId): self + { + $this->setValue('id', $messageId); + return $this; + } + + public function setType(string $type): self + { + $this->setValue('type', $type); + return $this; + } + + public function setMessage(string $message): self + { + $this->setValue('message', $message); + return $this; + } + + /** + * @param string[] $domains + */ + public function setDomains(array $domains): self + { + $this->setValue('domains', $domains); + return $this; + } + + public function setCreatedAt(DateTimeInterface $createdAt): self + { + $this->setValue('created_at', $createdAt->format('Y-m-d H:i:s')); + return $this; + } + + public function setExtra(?array $extra): self + { + $this->setValue('extra', $extra); + return $this; + } +} diff --git a/src/Data/DomainResult.php b/src/Data/DomainResult.php new file mode 100644 index 0000000..39a4669 --- /dev/null +++ b/src/Data/DomainResult.php @@ -0,0 +1,71 @@ + ['required', 'alpha-dash'], + // 'tld' => ['required', 'alpha-dash-dot'], + 'id' => ['required', 'string'], + 'domain' => ['required', 'string'], + 'statuses' => ['present', 'array'], + 'statuses.*' => ['filled', 'string'], + 'locked' => ['nullable', 'boolean'], + 'registrant' => ['nullable', ContactData::class], + 'billing' => ['nullable', ContactData::class], + 'tech' => ['nullable', ContactData::class], + 'admin' => ['nullable', ContactData::class], + 'ns' => ['present', NameserversParams::class], + 'created_at' => ['present', 'nullable', 'date_format:Y-m-d H:i:s'], + 'updated_at' => ['present', 'nullable', 'date_format:Y-m-d H:i:s'], + 'expires_at' => ['present', 'nullable', 'date_format:Y-m-d H:i:s'], + ]); + } + + /** + * Set the expires_at value. + * + * @return static $this + */ + public function setExpiresAt(DateTimeInterface $expiresAt) + { + $this->setValue('expires_at', $expiresAt->format('Y-m-d H:i:s')); + return $this; + } + + /** + * Set the domain lock status. + * + * @return static $this + */ + public function setLocked(?bool $locked) + { + $this->setValue('locked', $locked); + return $this; + } +} diff --git a/src/Data/EppCodeResult.php b/src/Data/EppCodeResult.php new file mode 100644 index 0000000..3087f3d --- /dev/null +++ b/src/Data/EppCodeResult.php @@ -0,0 +1,23 @@ + ['required', 'string'], + ]); + } +} diff --git a/src/Data/EppParams.php b/src/Data/EppParams.php new file mode 100644 index 0000000..b8b232f --- /dev/null +++ b/src/Data/EppParams.php @@ -0,0 +1,25 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + ]); + } +} diff --git a/src/Data/IpsTagParams.php b/src/Data/IpsTagParams.php new file mode 100644 index 0000000..3e693f3 --- /dev/null +++ b/src/Data/IpsTagParams.php @@ -0,0 +1,27 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'ips_tag' => ['required'], + ]); + } +} diff --git a/src/Data/LockParams.php b/src/Data/LockParams.php new file mode 100644 index 0000000..a68b5e0 --- /dev/null +++ b/src/Data/LockParams.php @@ -0,0 +1,27 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'lock' => ['required', 'boolean'], + ]); + } +} diff --git a/src/Data/Nameserver.php b/src/Data/Nameserver.php new file mode 100644 index 0000000..d05f18b --- /dev/null +++ b/src/Data/Nameserver.php @@ -0,0 +1,25 @@ + ['required', 'alpha-dash-dot'], + 'ip' => ['nullable', 'ip'], + ]); + } +} diff --git a/src/Data/NameserversParams.php b/src/Data/NameserversParams.php new file mode 100644 index 0000000..01a2ecc --- /dev/null +++ b/src/Data/NameserversParams.php @@ -0,0 +1,31 @@ + [Nameserver::class], + 'ns2' => [Nameserver::class], + 'ns3' => [Nameserver::class], + 'ns4' => [Nameserver::class], + 'ns5' => [Nameserver::class], + ]); + } +} diff --git a/src/Data/NameserversResult.php b/src/Data/NameserversResult.php new file mode 100644 index 0000000..02016b1 --- /dev/null +++ b/src/Data/NameserversResult.php @@ -0,0 +1,31 @@ + [Nameserver::class], + 'ns2' => [Nameserver::class], + 'ns3' => [Nameserver::class], + 'ns4' => [Nameserver::class], + 'ns5' => [Nameserver::class], + ]); + } +} diff --git a/src/Data/PollParams.php b/src/Data/PollParams.php new file mode 100644 index 0000000..c710a1a --- /dev/null +++ b/src/Data/PollParams.php @@ -0,0 +1,23 @@ + ['required', 'integer'], + 'after_date' => ['nullable', 'date_format:Y-m-d H:i:s'], + ]); + } +} diff --git a/src/Data/PollResult.php b/src/Data/PollResult.php new file mode 100644 index 0000000..dd377c1 --- /dev/null +++ b/src/Data/PollResult.php @@ -0,0 +1,25 @@ + ['required', 'integer'], + 'notifications' => ['present', 'array'], + 'notifications.*' => [DomainNotification::class] + ]); + } +} diff --git a/src/Data/RegisterContactParams.php b/src/Data/RegisterContactParams.php new file mode 100644 index 0000000..6e58c9c --- /dev/null +++ b/src/Data/RegisterContactParams.php @@ -0,0 +1,25 @@ + ['required_without:register', 'string'], + 'register' => ['required_without:id', ContactParams::class], + ]); + } +} diff --git a/src/Data/RegisterDomainParams.php b/src/Data/RegisterDomainParams.php new file mode 100644 index 0000000..8330593 --- /dev/null +++ b/src/Data/RegisterDomainParams.php @@ -0,0 +1,37 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'renew_years' => ['required', 'integer', 'max:10'], + 'registrant' => ['required', RegisterContactParams::class], + 'billing' => ['required', RegisterContactParams::class], + 'tech' => ['required', RegisterContactParams::class], + 'admin' => ['required', RegisterContactParams::class], + 'nameservers' => ['required', NameserversParams::class], + ]); + } +} diff --git a/src/Data/RenewParams.php b/src/Data/RenewParams.php new file mode 100644 index 0000000..0fbb990 --- /dev/null +++ b/src/Data/RenewParams.php @@ -0,0 +1,27 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'renew_years' => ['required', 'integer', 'max:10'], + ]); + } +} diff --git a/src/Data/TransferParams.php b/src/Data/TransferParams.php new file mode 100644 index 0000000..7a252c6 --- /dev/null +++ b/src/Data/TransferParams.php @@ -0,0 +1,31 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'renew_years' => ['integer', 'max:10'], + 'epp_code' => ['string'], + 'admin' => ['required', RegisterContactParams::class], + ]); + } +} diff --git a/src/Data/UpdateDomainContactParams.php b/src/Data/UpdateDomainContactParams.php new file mode 100644 index 0000000..045c6e8 --- /dev/null +++ b/src/Data/UpdateDomainContactParams.php @@ -0,0 +1,27 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'contact' => ['required', ContactParams::class], + ]); + } +} diff --git a/src/Data/UpdateNameserversParams.php b/src/Data/UpdateNameserversParams.php new file mode 100644 index 0000000..2fd895f --- /dev/null +++ b/src/Data/UpdateNameserversParams.php @@ -0,0 +1,35 @@ + ['required', 'alpha-dash'], + 'tld' => ['required', 'alpha-dash-dot'], + 'ns1' => ['required', Nameserver::class], + 'ns2' => ['required', Nameserver::class], + 'ns3' => ['nullable', Nameserver::class], + 'ns4' => ['nullable', Nameserver::class], + 'ns5' => ['nullable', Nameserver::class], + ]); + } +} diff --git a/src/DomainNameApi/Data/DomainNameApiConfiguration.php b/src/DomainNameApi/Data/DomainNameApiConfiguration.php new file mode 100644 index 0000000..ed43ab2 --- /dev/null +++ b/src/DomainNameApi/Data/DomainNameApiConfiguration.php @@ -0,0 +1,29 @@ + ['required', 'string'], + 'password' => ['required', 'string'], + 'sandbox' => ['boolean'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/DomainNameApi/Provider.php b/src/DomainNameApi/Provider.php new file mode 100644 index 0000000..2cb86db --- /dev/null +++ b/src/DomainNameApi/Provider.php @@ -0,0 +1,473 @@ + 'ns1.domainnameapi.com'], + ['host' => 'ns2.domainnameapi.com'] + ]; + + public function __construct(DomainNameApiConfiguration $configuration) + { + $this->configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('Domain Name Api') + ->setDescription('Register, transfer, renew and manage domains, with over 700+ TLDs available'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + $request = (new CheckAvailabilityRequest()) + ->setDomainNameList(new ArrayOfstring(array_fill(0, count($params->tlds), $params->sld))) + ->setTldList(new ArrayOfstring(array_map(fn ($tld) => Utils::normalizeTld($tld), $params->tlds))) + ->setPeriod(10); + $response = $this->api()->CheckAvailability(new CheckAvailability($request)); + $result = $response->getCheckAvailabilityResult(); + + $this->assertResultSuccess($result); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $ownNameServers = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $ownNameServers[] = Arr::get($params, 'nameservers.ns' . $i)['host']; + } + } + + $nameServers = $ownNameServers ?: self::NAMESERVERS; + + $request = (new RegisterWithContactInfoRequest()) + ->setDomainName($domain) + ->setPeriod(intval($params->renew_years)) + ->setNameServerList(new ArrayOfstring($nameServers)) + ->setLockStatus(true) + ->setPrivacyProtectionStatus(true) + ->setRegistrantContact($this->contactParamsToSoap($params->registrant->register)) + ->setAdministrativeContact($this->contactParamsToSoap($params->admin->register)) + ->setBillingContact($this->contactParamsToSoap($params->billing->register)) + ->setTechnicalContact($this->contactParamsToSoap($params->tech->register)); + + $response = $this->api()->RegisterWithContactInfo(new RegisterWithContactInfo($request)); + $result = $response->getRegisterWithContactInfoResult(); + + if (!$domainInfo = $result->getDomainInfo()) { + if ($result->getErrorCode() == 2302) { + $errorMessage = 'Domain name already exists'; + } + + throw $this->handleApiErrorResult($result, $errorMessage ?? null); + } + + return $this->domainInfoToResult($domainInfo) + ->setMessage('Domain registered successfully'); + } + + public function transfer(TransferParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + try { + return $this->getDomainResult($domainName, true) + ->setMessage('Domain active in registrar account'); + } catch (ProvisionFunctionError $e) { + // initiate transfer ... + } + + $request = (new TransferRequest()) + ->setDomainName($domainName) + ->setAuthCode($params->epp_code ?: null); + $response = $this->api()->Transfer(new Transfer($request)); + $result = $response->getTransferResult(); + + $this->assertResultSuccess($result); + + return $this->errorResult('Domain transfer initiated'); + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $renewRequest = (new RenewRequest()) + ->setDomainName($domain) + ->setPeriod(intval($params->renew_years)); + $response = $this->api()->Renew(new Renew($renewRequest)); + $result = $response->getRenewResult(); + + $this->assertResultSuccess($result); + + return $this->getDomainResult($domain) + ->setMessage('Domain renewed successfully'); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + return $this->getDomainResult($domain); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + $nameservers = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $nameservers[] = Arr::get($params, 'ns' . $i)['host']; + } + } + + $request = (new ModifyNameServerRequest()) + ->setDomainName($domainName) + ->setNameServerList(new ArrayOfstring($nameservers)); + $response = $this->api()->ModifyNameServer(new ModifyNameServer($request)); + $result = $response->getModifyNameServerResult(); + + $this->assertResultSuccess($result); + + $returnNameservers = collect($nameservers) + ->mapWithKeys(fn ($ns, $i) => ['ns' . ($i + 1) => $ns]) + ->toArray(); + + return NameserversResult::create($returnNameservers) + ->setMessage('Nameservers are changed'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + $domainInfo = $this->getDomainInfo(Utils::getDomain($params->sld, $params->tld)); + + return EppCodeResult::create(['epp_code' => $domainInfo->getAuth()]); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported'); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $contactResults = $this->getContactResults($domain); + + $request = (new SaveContactsRequest()) + ->setDomainName($domain) + ->setRegistrantContact($this->contactParamsToSoap($params->contact)) + ->setAdministrativeContact($this->contactParamsToSoap(new ContactParams($contactResults['admin']))) + ->setTechnicalContact($this->contactParamsToSoap(new ContactParams($contactResults['tech']))) + ->setBillingContact($this->contactParamsToSoap(new ContactParams($contactResults['billing']))); + $response = $this->api()->SaveContacts(new SaveContacts($request)); + $result = $response->getSaveContactsResult(); + + $this->assertResultSuccess($result); + + return $this->getContactResults($domain)['registrant']->setMessage('Registrant contact updated'); + } + + public function setLock(LockParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $domainResult = $this->getDomainResult($domainName); + + if ($domainResult->locked == $params->lock) { + return $domainResult + ->setMessage(sprintf('Domain already %s', $params->lock ? 'locked' : 'unlocked')); + } + + if ($params->lock) { + $request = (new EnableTheftProtectionLockRequest()) + ->setDomainName($domainName); + $response = $this->api()->EnableTheftProtectionLock(new EnableTheftProtectionLock($request)); + $result = $response->getEnableTheftProtectionLockResult(); + } else { + $request = (new DisableTheftProtectionLockRequest()) + ->setDomainName($domainName); + $response = $this->api()->DisableTheftProtectionLock(new DisableTheftProtectionLock($request)); + $result = $response->getDisableTheftProtectionLockResult(); + } + + $this->assertResultSuccess($result); + + return $domainResult + ->setMessage(sprintf('Domain successfully %s', $params->lock ? 'locked' : 'unlocked')) + ->setLocked(!!$params->lock); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('Operation not supported'); + } + + protected function getDomainResult(string $domain, bool $assertActive = false): DomainResult + { + return $this->domainInfoToResult($this->getDomainInfo($domain, $assertActive)) + ->setMessage('Domain info retrieved'); + } + + protected function getDomainInfo(string $domain, bool $assertActive = false): DomainInfo + { + $getDetailsRequest = (new GetDetailsRequest()) + ->setDomainName($domain); + $response = $this->api()->GetDetails(new GetDetails($getDetailsRequest)); + $result = $response->getGetDetailsResult(); + + if (!$domainInfo = $result->getDomainInfo()) { + throw $this->handleApiErrorResult($result); + } + + if ($assertActive && $domainInfo->getStatus() !== 'Active') { + throw $this->errorResult(sprintf('Domain is %s', $domainInfo->getStatus()), [ + 'domain' => $domain, + 'statuses' => [ + $domainInfo->getStatus(), + $domainInfo->getStatusCode(), + ] + ]); + } + + return $domainInfo; + } + + /** + * @return ContactResult[]|array + */ + protected function getContactResults(string $domainName): array + { + $request = (new GetContactsRequest()) + ->setDomainName($domainName); + $response = $this->api()->GetContacts(new GetContacts($request)); + $result = $response->getGetContactsResult(); + + if (!$result->getRegistrantContact()) { + throw $this->handleApiErrorResult($result); + } + + return [ + 'registrant' => $this->contactInfoToResult($result->getRegistrantContact()), + 'billing' => $this->contactInfoToResult($result->getBillingContact()), + 'tech' => $this->contactInfoToResult($result->getTechnicalContact()), + 'admin' => $this->contactInfoToResult($result->getAdministrativeContact()), + ]; + } + + protected function contactInfoToResult(?ContactInfo $contactInfo): ?ContactResult + { + if (empty($contactInfo)) { + return null; + } + + if ($contactInfo->getPhone()) { + $phone = '+' . $contactInfo->getPhoneCountryCode() . $contactInfo->getPhone(); + } + + return ContactResult::create(array_map(fn ($value) => $value !== 'n/a' ? $value : null, [ + 'id' => (string)$contactInfo->getId(), + 'name' => trim($contactInfo->getFirstName() . ' ' . $contactInfo->getLastName()), + 'organisation' => $contactInfo->getCompany(), + 'email' => $contactInfo->getEmail(), + 'phone' => $phone ?? null, + 'address1' => $contactInfo->getAddressLine1(), + 'city' => $contactInfo->getCity(), + 'state' => $contactInfo->getState(), + 'postcode' => $contactInfo->getZipCode(), + 'country_code' => $contactInfo->getCountry(), + ])); + } + + protected function domainInfoToResult(DomainInfo $domainInfo): DomainResult + { + $contacts = $this->getContactResults($domainInfo->getDomainName()); + $nameservers = collect($domainInfo->getNameServerList()->getString()) + ->mapWithKeys(fn ($host, $i) => ['ns' . ($i + 1) => ['host' => $host]]); + + return DomainResult::create([ + 'id' => (string)$domainInfo->getId(), + 'domain' => $domainInfo->getDomainName(), + 'statuses' => array_filter([$domainInfo->getStatus() ?? 'Unknown', $domainInfo->getStatusCode()]), + 'locked' => $domainInfo->getLockStatus(), + 'registrant' => $contacts['registrant'], + 'billing' => $contacts['billing'], + 'tech' => $contacts['tech'], + 'admin' => $contacts['admin'], + 'ns' => $nameservers, + 'created_at' => $this->formatDate($domainInfo->getTransferDate() ?? $domainInfo->getStartDate()), + 'updated_at' => $this->formatDate($domainInfo->getUpdatedDate()), + 'expires_at' => $this->formatDate($domainInfo->getExpirationDate()), + ]); + } + + protected function formatDate(?string $date): ?string + { + if (!isset($date)) { + return $date; + } + return Carbon::parse($date)->toDateTimeString(); + } + + protected function contactParamsToSoap(ContactParams $params): ContactInfo + { + @[$firstName, $lastName] = explode(' ', $params->name ?: $params->organisation, 2); + + $eppPhone = Utils::internationalPhoneToEpp($params->phone); + $phoneDiallingCode = trim(Str::before($eppPhone, '.'), '+'); + $phoneNumber = Str::after($eppPhone, '.'); + + return (new ContactInfo()) + ->setType(ContactType::VALUE_CONTACT) + ->setFirstName($firstName) + ->setLastName($lastName ?? $firstName) + ->setCompany($params->organisation) + ->setAddressLine1($params->address1) + ->setCity($params->city) + ->setState(strtoupper($params->country_code) === 'US' ? $params->state : null) + ->setZipCode($params->postcode) + ->setCountry($params->country_code) + ->setEMail($params->email) + ->setPhoneCountryCode($phoneDiallingCode) + ->setPhone($phoneNumber) + ->setStatus(''); + } + + /** + * @throws ProvisionFunctionError + */ + protected function assertResultSuccess(BaseMethodResponse $result, ?string $errorMessage = null): void + { + if ($result->getOperationResult() !== 'SUCCESS') { + $this->handleApiErrorResult($result, $errorMessage); + } + } + + /** + * @throws ProvisionFunctionError + * + * @return no-return + */ + protected function handleApiErrorResult(BaseMethodResponse $result, ?string $errorMessage = null): void + { + $errorMessage = $errorMessage ?: sprintf('Provider error: %s', $this->getApiErrorResultMessage($result)); + + throw $this->errorResult($errorMessage, [ + 'error_code' => $result->getErrorCode(), + 'operation_result' => $result->getOperationResult(), + 'operation_message' => $result->getOperationMessage(), + ]); + } + + protected function getApiErrorResultMessage(BaseMethodResponse $result): string + { + $message = $result->getOperationMessage() ?? 'Unknown error'; + + return str_replace('Invalid api request for filed', 'Invalid api request for field', $message); + } + + protected function api(): DomainNameApiSdkClient + { + if (isset($this->apiClient)) { + return $this->apiClient; + } + + return $this->apiClient = (new ClientFactory())->create( + $this->configuration->username, + $this->configuration->password, + $this->configuration->sandbox ? ClientFactory::ENV_TEST : ClientFactory::ENV_LIVE, + $this->configuration->debug ? $this->getLogger() : null + ); + } +} diff --git a/src/Enom/Data/Configuration.php b/src/Enom/Data/Configuration.php new file mode 100644 index 0000000..264d612 --- /dev/null +++ b/src/Enom/Data/Configuration.php @@ -0,0 +1,29 @@ + ['required', 'string', 'min:3'], + 'api_token' => ['required', 'string', 'min:6'], + 'sandbox' => ['nullable', 'boolean'], + 'debug' => ['nullable', 'boolean'], + ]); + } +} diff --git a/src/Enom/Helper/EnomApi.php b/src/Enom/Helper/EnomApi.php new file mode 100644 index 0000000..9002119 --- /dev/null +++ b/src/Enom/Helper/EnomApi.php @@ -0,0 +1,739 @@ +client = $client; + $this->configuration = $configuration; + } + + /** + * Get domain info + * + * @param string $sld + * @param string $tld + * @return array + */ + public function getDomainInfo(string $sld, string $tld): array + { + // Params for basic domain info + $params = [ + 'command' => 'GetDomainInfo', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $domainInfo = $this->makeRequest($params); + + // Get Contacts + $contacts = $this->getDomainContacts($sld, $tld); + + // Get NS from whois + $whois = $this->getDomainWhois($sld, $tld); + $nameServers = $whois['nameservers'] ?? []; + + // Domain Statuses + $status = $domainInfo->GetDomainInfo->status; + + return [ + 'id' => (string) $domainInfo->GetDomainInfo->domainname->attributes()['domainnameid'], + 'domain' => (string) $domainInfo->GetDomainInfo->{'domainname'}, + 'statuses' => [(string) $status->{'purchase-status'}, (string) $status->{'registrationstatus'}], + 'registrant' => $contacts['registrant'], + 'ns' => $this->parseNameservers($nameServers), + // Can't execute GetWhoisContact on test environment, so I wasn't able to test that. + 'created_at' => $this->configuration->sandbox ? Carbon::today()->toDateTimeString() : $whois['created_at'], + // Can't execute GetWhoisContact on test environment, so I wasn't able to test that. + 'updated_at' => $this->configuration->sandbox ? Carbon::today()->toDateTimeString() : $whois['updated_at'], + 'expires_at' => $this->configuration->sandbox ? Utils::formatDate((string) $status->{'expiration'}) : $whois['expires_at'], + 'locked' => $this->getRegLock($sld, $tld), + ]; + } + + /** + * @param string $sld + * @param string $tld + * @return array + */ + public function getDomainContacts(string $sld, string $tld): array + { + // Params for basic domain info + $params = [ + 'command' => 'GetContacts', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $contacts = $this->makeRequest($params); + + $registrant = $this->parseContact($contacts->GetContacts->Registrant, self::CONTACT_TYPE_REGISTRANT); + $admin = $this->parseContact($contacts->GetContacts->Admin, self::CONTACT_TYPE_ADMIN); + $tech = $this->parseContact($contacts->GetContacts->Tech, self::CONTACT_TYPE_TECH); + $billing = $this->parseContact($contacts->GetContacts->Billing, self::CONTACT_TYPE_BILLING); + + return compact('registrant', 'admin', 'tech', 'billing'); + } + + /** + * @return array + */ + public function getAccountDomains(): array + { + // Command Params + $params = [ + 'command' => 'GetAllDomains' + ]; + + $result = $this->makeRequest($params); + + $domains = []; + + foreach ($result->GetAllDomains->children() as $childTagName => $childTagData) { + // Process only domain records + if ($childTagName == 'DomainDetail') { + // Get TLD and SLD + $parts = Utils::getSldTld((string) $childTagData->DomainName); + + $domains[] = [ + 'sld' => $parts['sld'], + 'tld' => $parts['tld'], + 'domain' => (string) $childTagData->DomainName, + 'created_at' => '', + 'expires_at' => Utils::formatDate((string) $childTagData->{'expiration-date'}), + ]; + } + } + + return $domains; + } + + /** + * The EPP code itself is not returned (no way to obtain it from eNom), but will be sent to the email if the code exists. + * + * @param string $sld + * @param string $tld + * @return void + */ + public function getEppCode(string $sld, string $tld): void + { + // Command params + $params = [ + 'command' => 'SynchAuthInfo', + 'SLD' => $sld, + 'TLD' => $tld, + 'EmailEPP' => 'True', + 'RunSynchAutoInfo' => 'True' + ]; + + $result = $this->makeRequest($params); + } + + /** + * @param string $sld + * @param string $tld + * @return string + */ + public function setDomainPassword(string $sld, string $tld): string + { + // Generate Password + $password = bin2hex(random_bytes(20)); + + // Command params + $params = [ + 'command' => 'SetPassword', + 'SLD' => $sld, + 'TLD' => $tld, + 'EmailEPP' => 'True', + 'RunSynchAutoInfo' => 'True', + 'DomainPassword' => $password + ]; + + $result = $this->makeRequest($params); + + return $password; + } + + /** + * @param string $sld + * @param string $tld + * @param bool $autoRenew + */ + public function setRenewalMode(string $sld, string $tld, bool $autoRenew): void + { + // Command params + $params = [ + 'command' => 'SetRenew', + 'SLD' => $sld, + 'TLD' => $tld, + 'RenewFlag' => (int) $autoRenew + ]; + + $result = $this->makeRequest($params); + } + + /** + * @param string $sld + * @param string $tld + * @param bool $lock + */ + public function setRegLock(string $sld, string $tld, bool $lock): void + { + // Command params + $params = [ + 'command' => 'SetRegLock', + 'SLD' => $sld, + 'TLD' => $tld, + 'UnlockRegistrar' => (string)intval(!$lock), + ]; + + $result = $this->makeRequest($params); + } + + /** + * @param string $sld + * @param string $tld + * @return bool + */ + public function getRegLock(string $sld, string $tld): bool + { + // Command params + $params = [ + 'command' => 'GetRegLock', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $result = $this->makeRequest($params); + + return (bool) (int) $result->{'reg-lock'}; + } + + /** + * @param string $sld + * @param string $tld + * @param $contactParams + * @param string $type + */ + public function createUpdateDomainContact( + string $sld, + string $tld, + ContactParams $contactParams, + string $type + ): void { + // Validate Contact Type first + self::validateContactType($type); + + // Prepare params + $nameParts = $this->getNameParts($contactParams->name ?? $contactParams->organisation); + + $params = [ + 'command' => 'Contacts', + 'SLD' => $sld, + 'TLD' => $tld, + 'ContactType' => strtoupper($type), + $type . 'FirstName' => $nameParts['firstName'], + $type . 'LastName' => $nameParts['lastName'], + $type . 'OrganizationName' => $contactParams->organisation, + $type . 'Address1' => $contactParams->address1, + $type . 'City' => $contactParams->city, + $type . 'PostalCode' => $contactParams->postcode, + $type . 'Country' => Utils::normalizeCountryCode($contactParams->country_code), + $type . 'EmailAddress' => $contactParams->email, + $type . 'Phone' => $contactParams->phone + ]; + + $result = $this->makeRequest($params); + } + + /** + * @param string $sld + * @param string $tld + * @return string + */ + public function getDomainPassword(string $sld, string $tld): string + { + // Command params + $params = [ + 'command' => 'GetPasswordBit', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $result = $this->makeRequest($params); + + return (string) $result->DomainPassword; + } + + /** + * @return array + */ + public function getDomainWhois(string $sld, string $tld): array + { + // Command params + $params = [ + 'command' => 'GetWhoisContact', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $whois = $this->makeRequest($params); + + $rrp = $whois->GetWhoisContacts->{'rrp-info'}; + + $nameServers = []; + + foreach ($rrp->nameserver->children() as $ns) { + $nameServers[] = strtolower((string) $ns); + } + + return [ + 'nameservers' => $nameServers, + 'created_at' => Utils::formatDate((string) $rrp->{'created-date'}), + 'updated_at' => Utils::formatDate((string) $rrp->{'updated-date'}), + 'expires_at' => Utils::formatDate((string) $rrp->{'registration-expiration-date'}) + ]; + } + + /** + * @param string $sld + * @param string $tld + * @param string[] $nameServers + * + * @return string[] + */ + public function modifyNameServers(string $sld, string $tld, array $nameServers): array + { + // Update Name Servers Command Params + $params = [ + 'command' => 'ModifyNS', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $nameServerNumber = 1; + + // Attach the new nameservers + foreach ($nameServers as $host) { + $params['NS' . $nameServerNumber] = $host; + + $nameServerNumber++; + } + + $this->makeRequest($params); + + $whois = $this->getDomainWhois($sld, $tld); + return $this->parseNameservers($whois['nameservers'] ?? []); + } + + /** + * @return int Order ID + */ + public function renew(string $sld, string $tld, int $period, bool $renewIdProtect = false): int + { + // Renew command params + $params = [ + 'command' => 'extend', + 'SLD' => $sld, + 'TLD' => $tld, + 'NumYears' => $period, + 'OverrideOrder' => 1 // allow multiple renewal orders in the same day + ]; + + $result = $this->makeRequest($params); + + $orderId = (int) $result->OrderID; + + if ($renewIdProtect) { + // Get Domain Info and Additional Services + $idProtectParams = [ + 'command' => 'RenewServices', + 'SLD' => $sld, + 'TLD' => $tld, + 'Service' => 'WPPS' + ]; + + $idProtectResult = $this->makeRequest($idProtectParams); + } + + return $orderId; + } + + /** + * @param array $nameServers + * @return array + */ + private function parseNameservers(array $nameServers): array + { + $result = []; + + if (count($nameServers) > 0) { + foreach ($nameServers as $i => $ns) { + $result['ns' . ($i + 1)] = [ + 'host' => $ns, + 'ip' => null // No IP address available + ]; + } + } + + return $result; + } + + /** + * @param string $domainList + * @return array + */ + public function checkMultipleDomains(string $domainList): array + { + // Params + $params = [ + 'command' => 'Check', + 'DomainList' => $domainList + ]; + + $result = $this->makeRequest($params); + + $domainResults = []; + + for ($i = 1; $i <= 30; $i++) { + if (isset($result->{'Domain' . $i})) { + $domainResults[] = [ + 'domain' => (string) $result->{'Domain' . $i}, + 'available' => (int) $result->{'RRPCode' . $i} == 210 ? true : false, + 'reason' => (string) $result->{'RRPText' . $i} + ]; + } + } + + return $domainResults; + } + + /** + * @return int Order ID + */ + public function register( + string $sld, + string $tld, + int $numYears, + ContactParams $contactParams, + ?array $nameServers = null, + bool $transferLock = true + ): int { + // Command Params + $nameParts = $this->getNameParts($contactParams->name ?? $contactParams->organisation); + + $params = [ + 'command' => 'Purchase', + 'SLD' => $sld, + 'TLD' => $tld, + 'NumYears' => $numYears, + 'UnLockRegistrar' => (int) (bool) !$transferLock, + 'RegistrantFirstName' => $nameParts['firstName'], + 'RegistrantLastName' => $nameParts['lastName'], + 'RegistrantOrganizationName' => $contactParams->organisation, + 'RegistrantAddress1' => $contactParams->address1, + 'RegistrantCity' => $contactParams->city, + 'RegistrantPostalCode' => $contactParams->postcode, + 'RegistrantCountry' => Utils::normalizeCountryCode($contactParams->country_code), + 'RegistrantEmailAddress' => $contactParams->email, + 'RegistrantPhone' => $contactParams->phone + ]; + + // Set NameServers + if (is_null($nameServers) || count($nameServers) < 1) { + $params['UseDNS'] = 'default'; + } else { + $nameServerNumber = 1; + + foreach ($nameServers as $host) { + $params['NS' . $nameServerNumber] = $host; + + $nameServerNumber++; + } + } + + $result = $this->makeRequest($params); + + return (int) $result->OrderID; + } + + /** + * @param string $sld + * @param string $tld + * @param string $eppCode + * @param bool $lock + * @param bool $autoRenew + * @return array + */ + public function initiateTransfer( + string $sld, + string $tld, + string $eppCode, + bool $lock = false, + bool $autoRenew = false + ): array { + // Command Params + $params = [ + 'command' => 'TP_CreateOrder', + 'SLD1' => $sld, + 'TLD1' => trim($tld, '.'), + 'AuthInfo1' => $eppCode, + 'OrderType' => 'Autoverification', + 'DomainCount' => 1, + 'Lock' => (int) $lock, + 'Renew' => (int) $autoRenew + ]; + + $transfer = $this->makeRequest($params); + + return [ + 'orderId' => (int) $transfer->transferorder->transferorderid, + 'status' => (string) $transfer->transferorder->statusdesc, + 'statusId' => (int) $transfer->transferorder->statusid, + 'date' => Utils::formatDate((string) $transfer->transferorder->orderdate) + ]; + } + + /** + * @param string $sld + * @param string $tld + * @return array|null + */ + public function getDomainTransferOrders(string $sld, string $tld): ?array + { + // Command Params + $params = [ + 'command' => 'TP_GetOrdersByDomain', + 'SLD' => $sld, + 'TLD' => $tld + ]; + + $result = $this->makeRequest($params); + + $orderCount = (int) $result->ordercount; + + $orders = []; + + if ($orderCount > 0) { + foreach ($result->children() as $key => $childData) { + if ((string) $key == 'TransferOrder') { + $orders[] = [ + 'orderId' => (int) $childData->transferorderid, + 'status' => (string) $childData->orderstatus, + 'statusId' => (int) $childData->statusid, + 'date' => Utils::formatDate((string) $childData->orderdate) + ]; + } + } + + if (count($orders) > 0) { + return $orders; + } + } + + return null; + } + + /** + * @param string|null $name + * @return array + */ + private function getNameParts(?string $name): array + { + $nameParts = explode(" ", $name); + $firstName = array_shift($nameParts); + $lastName = implode(" ", $nameParts); + + return compact('firstName', 'lastName'); + } + + /** + * @param \SimpleXMLElement $rawContactData + * @param string $type Contact Type (Registrant, Tech, Admin, Billing) + * @return DomainContactInfo + */ + private function parseContact(\SimpleXMLElement $rawContactData, string $type): DomainContactInfo + { + // Check if our contact type is valid + self::validateContactType($type); + + return DomainContactInfo::create([ + 'contact_id' => $type, // Using type here, because sometimes there's no obtainable PartyId + 'name' => sprintf('%s %s', (string) $rawContactData->{$type . 'FirstName'}, (string) $rawContactData->{$type . 'LastName'}), + 'email' => (string) $rawContactData->{$type . 'EmailAddress'}, + 'phone' => (string) $rawContactData->{$type . 'Phone'}, + 'organisation' => (string) $rawContactData->{$type . 'OrganizationName'}, + 'address1' => (string) $rawContactData->{$type . 'Address1'}, + 'city' => (string) $rawContactData->{$type . 'City'}, + 'postcode' => (string) $rawContactData->{$type . 'PostalCode'}, + 'country_code' => (string) $rawContactData->{$type . 'Country'}, + 'type' => null, + ]); + } + + /** + * @param string $type + * + * @throws InvalidArgumentException + */ + public static function validateContactType(string $type): void + { + if (!in_array(strtolower($type), self::ALLOWED_CONTACT_TYPES)) { + throw new InvalidArgumentException(sprintf('Invalid contact type %s used!', $type)); + } + } + + /** + * Send request and return the response. + * + * @param array $params + * + * @return SimpleXMLElement + * + * @throws ProvisionFunctionError + */ + public function makeRequest(array $params): SimpleXMLElement + { + // Prepare command params + $params = array_merge([ + 'UID' => $this->configuration->username, + 'PW' => $this->configuration->api_token, + ], $params); + + $httpQuery = [ + 'responseType' => 'xml' + ]; + + // Format params + foreach ($params as $key => $param) { + if (is_array($param)) { + $param = implode(',', $param); + } + + if (strtolower($key) == 'tld' && substr($param, 0, 1) == '.') { + $param = substr($param, 1); + } + + $httpQuery[$key] = $param; + } + + $response = $this->client->get( + $this->configuration->sandbox + ? 'https://resellertest.enom.com/interface.asp' + : 'https://reseller.enom.com/interface.asp', + [ + 'query' => $httpQuery + ] + ); + + $result = $response->getBody()->__toString(); + $response->getBody()->close(); + + // Init cUrl + if (empty($result)) { + // Something bad happened... + throw new RuntimeException('Empty enom api response'); + } + + return $this->parseResponseData($result); + } + + /** + * Parse and process the XML Response + * + * @param string $result + * + * @return SimpleXMLElement + * + * @throws ProvisionFunctionError + */ + private function parseResponseData(string $result): SimpleXMLElement + { + // Try to parse the response + $xml = simplexml_load_string($result, 'SimpleXMLElement', LIBXML_NOCDATA); + + // Just in case... + if ($xml === false) { + throw ProvisionFunctionError::create('Unknown Provider API Error') + ->withData([ + 'response' => $result, + ]); + } + + // Check the XML for errors + if ( + (isset($xml->RRPCode) && (int)$xml->RRPCode != 200) + || (isset($xml->ErrCount) && $xml->ErrCount > 0) + ) { + throw ProvisionFunctionError::create($this->formatEnomErrorMessage((array)$xml->errors)) + ->withData([ + 'response' => $xml, + ]); + } + + return $xml; + } + + /** + * @param array $xmlErrors + * @return string + */ + private function formatEnomErrorMessage(array $xmlErrors): string + { + if (empty($xmlErrors)) { + $xmlErrors = ['Unknown error']; + } + + return sprintf('Provider API Error: %s', implode(', ', $xmlErrors)); + } +} diff --git a/src/Enom/Provider.php b/src/Enom/Provider.php new file mode 100644 index 0000000..7d9e4d4 --- /dev/null +++ b/src/Enom/Provider.php @@ -0,0 +1,497 @@ +configuration = $configuration; + } + + /** + * @return AboutData + */ + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('Enom') + ->setDescription('Register, transfer, renew and manage Enom domains') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/enom-logo@2x.png'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + // Get Domains + $domains = []; + + $max = 30; + $start = 0; + + foreach (Arr::get($params, 'domains') as $domain) { + $domains[] = Utils::getDomain($domain['sld'], $domain['tld']); + + $start++; + + // Allow up to 30 domains in one check + if ($start == $max) { + break; + } + } + + // Enom V1 only. For using V2 we will need to issue multiple requests for each domain name. + $domainList = rtrim(implode(",", $domains), ','); + + try { + $domainsCheck = $this->api()->checkMultipleDomains($domainList); + + return $this->okResult("Domain Check Results", $domainsCheck); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + /** + * @param RegisterDomainParams $params + * @return DomainResult + */ + public function register(RegisterDomainParams $params): DomainResult + { + // Get Params + $sld = $params->sld; + $tld = $params->tld; + $domain = Utils::getDomain($sld, $tld); + + try { + // eNom doesn't have contact IDs, so we must have the `register` part for each contact. + if (!Arr::has($params, 'registrant.register')) { + return $this->errorResult('Registrant contact data is required!'); + } + + if (!Arr::has($params, 'tech.register')) { + return $this->errorResult('Tech contact data is required!'); + } + + if (!Arr::has($params, 'admin.register')) { + return $this->errorResult('Admin contact data is required!'); + } + + if (!Arr::has($params, 'billing.register')) { + return $this->errorResult('Billing contact data is required!'); + } + + // Register the domain with the registrant contact data + $nameServers = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $nameServers[] = Arr::get($params, 'nameservers.ns' . $i)->host; + } + } + + // In case of success, update the rest of the contact types (admin, tech, billing) + $this->api()->register( + $sld, + $tld, + intval($params->renew_years), + $params->registrant->register, + $nameServers, // use custom name servers by default + false // allow future domain transfers by default + ); + + // TODO: eNom allows registering a domain only with the registrant contact data. In our case - we're passing all of the contact data, so we'll update it in the proper places after we have the domain registered. + $this->updateContact($sld, $tld, $params->admin->register, EnomApi::CONTACT_TYPE_ADMIN); + $this->updateContact($sld, $tld, $params->tech->register, EnomApi::CONTACT_TYPE_TECH); + $this->updateContact($sld, $tld, $params->billing->register, EnomApi::CONTACT_TYPE_BILLING); + + // Return newly fetched data for the domain + return $this->_getInfo($sld, $tld, sprintf('Domain %s was registered successfully!', $domain)); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param TransferParams $params + * @return DomainResult + */ + public function transfer(TransferParams $params): DomainResult + { + // Get the domain name + $sld = $params->sld; + $tld = $params->tld; + + $domain = Utils::getDomain($sld, $tld); + // TODO: `renew_years` (period) is not needed here. + //$period = Arr::get($params, 'renew_years', 1); + // TODO: In development, `epp_code` is not needed as well, but required when sending the requests, so we may just send a random string + $eppCode = $params->epp_code ?: '1234'; + + try { + // Check for previous order first + $prevOrder = $this->api()->getDomainTransferOrders($sld, $tld); + + if (is_null($prevOrder)) { + // Attempt to create a new transfer order. + $transfer = $this->api()->initiateTransfer($sld, $tld, $eppCode); + + // Transfer is most probably still pending, so we'll return just a basic info with the transfer order id, the domain and the order creation date + return DomainResult::create([ + 'id' => (string) $transfer['orderId'], + 'domain' => $domain, + 'ns' => [], + 'statuses' => [], + 'created_at' => Utils::formatDate($transfer['date']), + 'updated_at' => Utils::formatDate($transfer['date']), + 'expires_at' => Utils::formatDate($transfer['date']) + ]); + } else { + $this->errorResult(sprintf('Transfer order(s) for %s already exists!', $domain), $prevOrder, $params); + } + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param RenewParams $params + * @return DomainResult + */ + public function renew(RenewParams $params): DomainResult + { + // Get the domain name + $sld = $params->sld; + $tld = $params->tld; + + $domain = Utils::getDomain($sld, $tld); + $period = $params->renew_years; + + try { + // Get Domain Info + // TODO: Should we check for ID Protect renewal? + $info = $this->_getInfo( + $sld, + $tld, + sprintf('Renewal for %s domain was successful!', $domain) + ); + + $this->api()->renew($sld, $tld, $period, false); + + return $info->setExpiresAt(Carbon::parse($info->expires_at)->addYears($period)); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param DomainInfoParams $params + * @return DomainResult + */ + public function getInfo(DomainInfoParams $params): DomainResult + { + try { + return $this->_getInfo($params->sld, $params->tld, 'Domain data obtained'); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param string $sld + * @param string $tld + * @param string $message + * @return DomainResult + */ + private function _getInfo(string $sld, string $tld, string $message): DomainResult + { + $domainInfo = $this->api()->getDomainInfo($sld, $tld); + return DomainResult::create($domainInfo)->setMessage($message); + } + + /** + * @param UpdateNameserversParams $params + * @return NameserversResult + */ + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + // Get Domain Name and NameServers + $domain = Utils::getDomain($params->sld, $params->tld); + + $nameServers = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $nameServer = Arr::get($params, 'ns' . $i); + $nameServers[] = $nameServer->host; + } + } + + try { + // Attempt to update domain name servers + $nameservers = $this->api()->modifyNameServers( + $params->sld, + $params->tld, + $nameServers + ); + + return NameserversResult::create($nameservers) + ->setMessage(sprintf('Name servers for %s domain were updated!', $domain)); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * Emails EPP code to the registrant's email address. + * + * @param EppParams $params + * @return EppCodeResult + */ + public function getEppCode(EppParams $params): EppCodeResult + { + $sld = $params->sld; + $tld = $params->tld; + + try { + // Check if the domain is locked + $regLock = $this->api()->getRegLock($sld, $tld); + + // Don't show error, but attempt to unlock + if ($regLock === true) { + $this->api()->setRegLock($sld, $tld, false); + //return $this->errorResult('Domain transfer is prohibited! Please, unlock it first!'); + } + + // Send EPP Code to the registrant + $this->api()->getEppCode($sld, $tld); + + // Restore lock + if ($regLock === true) { + $this->api()->setRegLock($sld, $tld, true); + } + + return EppCodeResult::create([ + 'epp_code' => 'Sent to registrant\'s email!' + ])->setMessage('EPP/Auth code obtained'); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param IpsTagParams $params + * @return ResultData + */ + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported', $params); + } + + /** + * @param UpdateDomainContactParams $params + * @return ContactResult + */ + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + return $this->updateContact($params->sld, $params->tld, $params->contact, EnomApi::CONTACT_TYPE_REGISTRANT); + } + + /** + * @param LockParams $params + * @return ResultData + */ + public function setLock(LockParams $params): DomainResult + { + // Get the domain name + $sld = $params->sld; + $tld = $params->tld; + $lock = !!$params->lock; + + $domain = Utils::getDomain($sld, $tld); + + try { + if (!$lock && !$this->api()->getRegLock($sld, $tld)) { + return $this->_getInfo($sld, $tld, 'Domain already unlocked'); + } + + $this->api()->setRegLock($sld, $tld, $lock); + + return $this->_getInfo($sld, $tld, sprintf("Lock %s!", $lock ? 'enabled' : 'disabled')); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param AutoRenewParams $params + * @return ResultData + */ + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + // Get the domain name + $sld = $params->sld; + $tld = $params->tld; + + $domain = Utils::getDomain($sld, $tld); + $autoRenew = !!$params->auto_renew; + + try { + $this->api()->setRenewalMode($sld, $tld, $autoRenew); + + return $this->_getInfo($sld, $tld, 'Auto-renew mode updated'); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @param string $sld + * @param string $tld + * @param ContactParams $params + * @param string $type + * @return ContactResult + */ + private function updateContact(string $sld, string $tld, ContactParams $params, string $type): ContactResult + { + try { + $this->api()->createUpdateDomainContact($sld, $tld, $params, $type); + + return ContactResult::create([ + 'contact_id' => strtolower($type), + 'name' => $params->name, + 'email' => $params->email, + 'phone' => $params->phone, + 'organisation' => $params->organisation, + 'address1' => $params->address1, + 'city' => $params->city, + 'postcode' => $params->postcode, + 'country_code' => Utils::normalizeCountryCode($params->country_code), + ]); + } catch (\Throwable $e) { + $this->handleException($e, $params); + } + } + + /** + * @throws ProvisionFunctionError + * + * @return no-return + */ + protected function handleException(Throwable $e, $params = null): void + { + if (!$e instanceof ProvisionFunctionError) { + $e = new ProvisionFunctionError('Unexpected Provider Error', $e->getCode(), $e); + } + + throw $e->withDebug([ + 'params' => $params, + ]); + } + + protected function api(): EnomApi + { + if (isset($this->api)) { + return $this->api; + } + + $client = new Client([ + 'base_uri' => $this->configuration->sandbox + ? 'https://resellertest.enom.com/interface.asp' + : 'https://reseller.enom.com/interface.asp', + 'headers' => [ + 'User-Agent' => 'Upmind/ProvisionProviders/DomainNames/Enom' + ], + 'connect_timeout' => 10, + 'timeout' => 60, + 'verify' => !$this->configuration->sandbox, + 'handler' => $this->getGuzzleHandlerStack(!!$this->configuration->debug), + ]); + + return $this->api = new EnomApi($client, $this->configuration); + } +} diff --git a/src/Helper/Countries.php b/src/Helper/Countries.php new file mode 100644 index 0000000..1f27e42 --- /dev/null +++ b/src/Helper/Countries.php @@ -0,0 +1,356 @@ + 'Ascension', + 'AD' => 'Andorra', + 'AE' => 'United Arab Emirates', + 'AF' => 'Afghanistan', + 'AG' => 'Antigua and Barbuda', + 'AI' => 'Anguilla', + 'AL' => 'Albania', + 'AM' => 'Armenia', + 'AN' => 'Netherland Antilles', + 'AO' => 'Angola', + 'AQ' => 'Antarctica', + 'AR' => 'Argentina', + 'AS' => 'American Samoa', + 'AT' => 'Austria', + 'AU' => 'Australia', + 'AW' => 'Aruba', + 'AZ' => 'Azerbaidjan', + 'BA' => 'Bosnia-Herzegovina', + 'BB' => 'Barbados', + 'BD' => 'Banglades', + 'BE' => 'Belgium', + 'BF' => 'Burkina Faso', + 'BG' => 'Bulgaria', + 'BH' => 'Bahrain', + 'BI' => 'Burundi', + 'BJ' => 'Benin', + 'BM' => 'Bermuda', + 'BN' => 'Brunei Darussalam', + 'BO' => 'Bolivia', + 'BR' => 'Brazil', + 'BS' => 'Bahamas', + 'BT' => 'Buthan', + 'BV' => 'Bouvet Island', + 'BW' => 'Botswana', + 'BY' => 'Belarus', + 'BZ' => 'Belize', + 'CA' => 'Canada', + 'CC' => 'Cocos (Keeling) Islands', + 'CF' => 'Central African Republic', + 'CG' => 'Congo', + 'CH' => 'Switzerland', + 'CI' => 'Ivory Coast', + 'CK' => 'Cook Islands', + 'CL' => 'Chile', + 'CM' => 'Cameroon', + 'CN' => 'China', + 'CO' => 'Colombia', + 'CR' => 'Costa Rica', + 'CS' => 'Czechoslovakia', + 'CU' => 'Cuba', + 'CV' => 'Cape Verde', + 'CX' => 'Christmas Island', + 'CY' => 'Cyprus', + 'CZ' => 'Czech Republic', + 'DE' => 'Germany', + 'DJ' => 'Djibouti', + 'DK' => 'Denmark', + 'DM' => 'Dominica', + 'DO' => 'Dominican Republic', + 'DZ' => 'Algeria', + 'EC' => 'Ecuador', + 'EE' => 'Estonia', + 'EG' => 'Egypt', + 'EH' => 'Western Sahara', + 'ES' => 'Spain', + 'ET' => 'Ethiopia', + 'FI' => 'Finland', + 'FJ' => 'Fiji', + 'FK' => 'Falkland Islands (Malvinas)', + 'FM' => 'Micronesia', + 'FO' => 'Faroe Islands', + 'FR' => 'France', + 'GA' => 'Gabon', + 'GB' => 'Great Britain', + 'GD' => 'Grenada', + 'GE' => 'Georgia', + 'GH' => 'Ghana', + 'GI' => 'Gibraltar', + 'GL' => 'Greenland', + 'GP' => 'Guadeloupe (French)', + 'GQ' => 'Equatorial Guinea', + 'GF' => 'Guyana (French)', + 'GM' => 'Gambia', + 'GN' => 'Guinea', + 'GR' => 'Greece', + 'GS' => 'South Georgia and South Sandwich Islands', + 'GT' => 'Guatemala', + 'GU' => 'Guam (US)', + 'GW' => 'Guinea Bissau', + 'GY' => 'Guyana', + 'HK' => 'Hong Kong', + 'HM' => 'Heard and McDonald Islands', + 'HN' => 'Honduras', + 'HR' => 'Croatia', + 'HT' => 'Haiti', + 'HU' => 'Hungary', + 'ID' => 'Indonesia', + 'IE' => 'Ireland', + 'IL' => 'Israel', + 'IN' => 'India', + 'IO' => 'British Indian Ocean Territory', + 'IQ' => 'Iraq', + 'IR' => 'Iran', + 'IS' => 'Iceland', + 'IT' => 'Italy', + 'JM' => 'Jamaica', + 'JO' => 'Jordan', + 'JP' => 'Japan', + 'KE' => 'Kenya', + 'KG' => 'Kirgistan', + 'KH' => 'Cambodia', + 'KI' => 'Kiribati', + 'KM' => 'Comoros', + 'KN' => 'Saint Kitts Nevis Anguilla', + 'KP' => 'North Korea', + 'KR' => 'South Korea', + 'KW' => 'Kuwait', + 'KY' => 'Cayman Islands', + 'KZ' => 'Kazachstan', + 'LA' => 'Laos', + 'LB' => 'Lebanon', + 'LC' => 'Saint Lucia', + 'LI' => 'Liechtenstein', + 'LK' => 'Sri Lanka', + 'LR' => 'Liberia', + 'LS' => 'Lesotho', + 'LT' => 'Lithuania', + 'LU' => 'Luxembourg', + 'LV' => 'Latvia', + 'LY' => 'Libya', + 'MA' => 'Morocco', + 'MC' => 'Monaco', + 'MD' => 'Moldavia', + 'MG' => 'Madagascar', + 'MH' => 'Marshall Islands', + 'ML' => 'Mali', + 'MM' => 'Myanmar', + 'MN' => 'Mongolia', + 'MO' => 'Macau', + 'MP' => 'Northern Mariana Islands', + 'MQ' => 'Martinique (French)', + 'MR' => 'Mauritania', + 'MS' => 'Montserrat', + 'MT' => 'Malta', + 'MU' => 'Mauritius', + 'MV' => 'Maldives', + 'MW' => 'Malawi', + 'MX' => 'Mexico', + 'MY' => 'Malaysia', + 'MZ' => 'Mozambique', + 'NA' => 'Namibia', + 'NC' => 'New Caledonia (French)', + 'NE' => 'Niger', + 'NF' => 'Norfolk Island', + 'NG' => 'Nigeria', + 'NI' => 'Nicaragua', + 'NL' => 'Netherlands', + 'NO' => 'Norway', + 'NP' => 'Nepal', + 'NR' => 'Nauru', + 'NT' => 'Neutral Zone', + 'NU' => 'Niue', + 'NZ' => 'New Zealand', + 'OM' => 'Oman', + 'PA' => 'Panama', + 'PE' => 'Peru', + 'PF' => 'Polynesia (French)', + 'PG' => 'Papua New', + 'PH' => 'Philippines', + 'PK' => 'Pakistan', + 'PL' => 'Poland', + 'PM' => 'Saint Pierre and Miquelon', + 'PN' => 'Pitcairn', + 'PT' => 'Portugal', + 'PR' => 'Puerto Rico (US)', + 'PW' => 'Palau', + 'PY' => 'Paraguay', + 'QA' => 'Qatar', + 'RE' => 'Reunion (French)', + 'RO' => 'Romania', + 'RU' => 'Russian Federation', + 'RW' => 'Rwanda', + 'SA' => 'Saudi Arabia', + 'SB' => 'Solomon Islands', + 'SC' => 'Seychelles', + 'SD' => 'Sudan', + 'SE' => 'Sweden', + 'SG' => 'Singapore', + 'SH' => 'Saint Helena', + 'SI' => 'Slovenia', + 'SJ' => 'Svalbard and Jan Mayen Islands', + 'SK' => 'Slovak Republic', + 'SL' => 'Sierra Leone', + 'SM' => 'San Marino', + 'SN' => 'Senegal', + 'SO' => 'Somalia', + 'SR' => 'Suriname', + 'ST' => 'Saint Tome and Principe', + 'SU' => 'Soviet Union', + 'SV' => 'El Salvador', + 'SY' => 'Syria', + 'SZ' => 'Swaziland', + 'TC' => 'Turks and Caicos Islands', + 'TD' => 'Chad', + 'TF' => 'French Southern Territory', + 'TG' => 'Togo', + 'TH' => 'Thailand', + 'TJ' => 'Tadjikistan', + 'TK' => 'Tokelau', + 'TM' => 'Turkmenistan', + 'TN' => 'Tunisia', + 'TO' => 'Tonga', + 'TP' => 'East Timor', + 'TR' => 'Turkey', + 'TT' => 'Trinidad and Tobago', + 'TV' => 'Tuvalu', + 'TW' => 'Taiwan', + 'TZ' => 'Tanzania', + 'UA' => 'Ukraine', + 'UG' => 'Uganda', + 'UK' => 'United Kingdom', + 'UM' => 'US Minor Outlying Islands', + 'US' => 'United States', + 'UY' => 'Uruguay', + 'UZ' => 'Uzbekistan', + 'VA' => 'Vatican City State', + 'VC' => 'Saint Vincent and Grenadines', + 'VE' => 'Venezuela', + 'VG' => 'Virgin Islands (British)', + 'VI' => 'Virgin Islands (US)', + 'VN' => 'Vietnam', + 'VU' => 'Vanuatu', + 'WF' => 'Wallis and Futuna Islands', + 'WS' => 'Samoa', + 'YE' => 'Yemen', + 'YU' => 'Yugoslavia', + 'ZA' => 'South Africa', + 'ZM' => 'Zambia', + 'ZR' => 'Zaire', + 'ZW' => 'Zimbabwe', + ]; + + /** + * Obtain the name of the given country code. + * + * @param string $countryCode ISO alpha-2 country code + * + * @return string|null Country name, or null if unknown + */ + public static function codeToName($countryCode): ?string + { + $countryCode = static::normalizeCode($countryCode); + + return static::$countries[$countryCode] ?? null; + } + + /** + * Obtain the code of the given country name. + * + * @param string $countryName Country name + * + * @return string|null ISO alpha-2 country code, or null if unknown + */ + public static function nameToCode($countryName): ?string + { + $search = strtolower(trim($countryName ?? '')); + if (empty($search)) { + return null; + } + + $countries = array_map('strtolower', static::$countries); + + if ($countryCode = array_search($search, $countries)) { + return $countryCode; + } + + // return closest match using levenshtein ?? + + return null; + } + + /** + * Normalize the given country code. + */ + public static function normalizeCode($countryCode): ?string + { + $countryCode = strtoupper(trim($countryCode ?? '')); + + if (empty($countryCode)) { + return null; + } + + switch ($countryCode) { + case 'UK': + return 'GB'; + default: + return $countryCode; + } + } + + /** + * @param string $countryCode + * @param string $stateName + * @return string|null + */ + public static function stateNameToCode(string $countryCode, string $stateName): ?string + { + if (!$countryCode = self::normalizeCode($countryCode)) { + return null; + } + + if (!$stateName = strtolower(trim($stateName ?? ''))) { + return null; + } + + $countries = new \PragmaRX\Countries\Package\Countries(); + return $countries->where('cca2', $countryCode) + ->first() + ->hydrateStates() + ->states + ->first(function ($state) use ($stateName) { + return strtolower($state->name) == $stateName; + }) + ->postal ?? null; + } + + public static function stateCodeToName(string $countryCode, string $stateCode): ?string + { + if (!$countryCode = self::normalizeCode($countryCode)) { + return null; + } + + if (!$stateCode = strtolower(trim($stateCode ?? ''))) { + return null; + } + + $countries = new \PragmaRX\Countries\Package\Countries(); + return $countries->where('cca2', $countryCode) + ->first() + ->hydrateStates() + ->states + ->first(function ($state) use ($stateCode) { + return strtolower($state->postal) == $stateCode; + }) + ->name ?? null; + } +} diff --git a/src/Helper/Utils.php b/src/Helper/Utils.php new file mode 100644 index 0000000..c0cbc1a --- /dev/null +++ b/src/Helper/Utils.php @@ -0,0 +1,274 @@ +format($format); + } + + return $dateObject->toDateTimeString(); + } + + /** + * Returns SLD and TLD from a domain, represented as a string. + * + * @param string $domain + * @return array + */ + public static function getSldTld(string $domain): array + { + $parts = explode('.', $domain, 2); + + return [ + 'sld' => array_shift($parts), + 'tld' => implode('.', $parts), + ]; + } + + /** + * Get the tld of the given domain name. + */ + public static function getTld(string $domain): string + { + return explode('.', $domain, 2)[1]; + } + + /** + * Get a fully formed domain name from its constituent raw second- and top-level parts. + * + * @param string $sld Second-level domain e.g., upmind + * @param string $tld Top-level domain e.g., .com + * + * @return string Domain name e.g., upmind.com + */ + public static function getDomain(string $sld, string $tld): string + { + return implode('.', [self::normalizeSld($sld), self::normalizeTld($tld)]); + } + + /** + * Normalize a second-level domain by stripping extra periods (.). + */ + public static function normalizeSld(string $sld): string + { + return trim(strtolower($sld), '.'); + } + + /** + * Normalize a top-level domain by trimming periods (.) and shifting + * to lowercase. + */ + public static function normalizeTld(string $tld): string + { + return trim(strtolower($tld), '.'); + } + + /** + * Returns the normalized root top-level domain for the given tld, for example + * given ".co.uk" returns "uk". + */ + public static function getRootTld(string $tld): string + { + $parts = explode('.', self::normalizeTld($tld)); + + return array_pop($parts); + } + + /** + * Use system DNS resolver to look up a domain's NS records. + * + * @param string $domain + * @param bool $orFail When lookup fails: if true throw an error, otherwise return null + * + * @return string[]|null Array of nameserver hostnames + * + * @throws ProvisionFunctionError + */ + public static function lookupNameservers(string $domain, bool $orFail = true): ?array + { + try { + return array_column(dns_get_record($domain, DNS_NS), 'target'); + } catch (Throwable $e) { + if ($orFail) { + throw new ProvisionFunctionError(sprintf('Nameserver lookup for %s failed', $domain), 0, $e); + } + + return null; + } + } + + /** + * Determine whether the registry of the given TLD supports explicit renewal. + */ + public static function tldSupportsExplicitRenewal(string $tld): bool + { + $unsupported = [ + 'abogado', + 'at', + 'be', + 'ch', + 'de', + 'fr', + 'gs', + 'it', + 'jobs', + 'li', + 'ltd', + 'nl', + 'pl', + 'pw', + 'tk', + ]; + + return !in_array(static::getRootTld($tld), $unsupported); + } + + /** + * Determine whether the registry of the given TLD supports WHOIS privacy. + */ + public static function tldSupportsWhoisPrivacy(string $tld): bool + { + $unsupported = [ + 'mx', + 'es', + ]; + + return !in_array(static::getRootTld($tld), $unsupported); + } + + /** + * Convert a phone from "international format" (beginning with `+` and intl + * dialling code) to "EPP format" described in RFC5733. To validate a phone + * number is in valid international format, you can use the provided + * `international_phone` rule. + * + * @link https://tools.ietf.org/html/rfc5733#section-2.5 + * + * @throws \Propaganistas\LaravelPhone\Exceptions\NumberParseException If not a valid international phone number + * + * @param string $number Phone number in "international format" E.g., +447515878251 + * + * @return string Phone number in "EPP format" E.g., +44.7515878251 + */ + public static function internationalPhoneToEpp(?string $number): ?string + { + if (empty($number)) { + return null; + } + + $phone = phone($number); + $diallingCode = $phone->getPhoneNumberInstance()->getCountryCode(); + + $prefix = sprintf('+%d', $diallingCode); + $suffix = Str::replaceFirst($prefix, '', (string)$phone); + + return sprintf('%s.%s', $prefix, $suffix); + } + + /** + * Convert a phone number from "EPP format" described in RFC5733 to "international + * format". + * + * @link https://tools.ietf.org/html/rfc5733#section-2.5 + * + * @throws \Propaganistas\LaravelPhone\Exceptions\NumberParseException If not a valid EPP format phone number + * + * @param string $eppNumber Phone number in "EPP format" E.g., +44.7515878251 + * + * @return string $number Phone number in "international format" E.g., +447515878251 + */ + public static function eppPhoneToInternational(string $eppNumber): string + { + return (string)phone($eppNumber); + } + + /** + * Normalize a phont number from local to international format. + * + * @param string $number Local format phone number + * @param string|null $countryCode Country code, if known + * + * @return string International format phone number, if possible + */ + public static function localPhoneToInternational(string $number, ?string $countryCode, bool $orFail = true): string + { + if (Str::startsWith($number, '+')) { + // our work here is done + return $number; + } + + try { + return (string)phone($number, $countryCode ?: []); + } catch (Throwable $e) { + if ($orFail) { + throw $e; + } + + // just return the input number + return $number; + } + } + + public static function codeToCountry(?string $countryCode): ?string + { + return Countries::codeToName($countryCode); + } + /** + * @param string|null $country + * @return string|null + */ + public static function countryToCode(?string $country): ?string + { + return Countries::nameToCode($country); + } + + /** + * Normalizes a given iso alpha-2 country code. + */ + public static function normalizeCountryCode(?string $countryCode): ?string + { + return Countries::normalizeCode($countryCode); + } + + public static function stateNameToCode(?string $countryCode, ?string $stateName): ?string + { + if (!$countryCode || !$stateName) { + return $stateName; + } + + return Countries::stateNameToCode($countryCode, $stateName) ?? $stateName; + } + + public static function stateCodeToName(?string $countryCode, ?string $stateCode): ?string + { + if (!$countryCode || !$stateCode) { + return $stateCode; + } + + return Countries::stateCodeToName($countryCode, $stateCode) ?? $stateCode; + } +} diff --git a/src/Hexonet/Dac.php b/src/Hexonet/Dac.php new file mode 100644 index 0000000..e1fb12d --- /dev/null +++ b/src/Hexonet/Dac.php @@ -0,0 +1,245 @@ +configuration = $configuration; + $this->client = $client; + $this->endpoint = $configuration->sandbox + ? 'https://api-ote.ispapi.net/api/call.cgi' + : 'https://api.ispapi.net/api/call.cgi'; + } + + /** + * Perform an availability search for the given SLD and TLDs. + * + * @param string $sld Second-level domain e.g., harrydev + * @param string[] $tlds Top-level domains e.g., ['.com', '.net'] + * @param string $language Language code + * + * @throws ProvisionFunctionError If DAC search fails + * + * @return DacResult + */ + public function search(string $sld, array $tlds, string $language = 'en'): DacResult + { + $postData = [ + 'command' => 'CheckDomains', + 's_entity' => $this->configuration->sandbox ? '1234' : '54cd', + 's_login' => $this->configuration->username, + 's_pw' => $this->configuration->password, + 'x-idn-language' => $language + ]; + + $domains = []; + + foreach (array_values($tlds) as $i => $tld) { + if (!$domain = self::getDomain($sld, $tld)) { + continue; + } + + $domains[$i] = $postData['domain' . $i] = $domain; + } + + if (empty($domains)) { + return DacResult::create([ + 'domains' => [] // return empty + ]); + } + + $response = $this->client()->post($this->endpoint, [ + RequestOptions::FORM_PARAMS => $postData, + RequestOptions::HTTP_ERRORS => false, + RequestOptions::TIMEOUT => 15, + RequestOptions::CONNECT_TIMEOUT => 5, + ]); + + return $this->processResponse($response, $domains); + } + + /** + * Process a DAC search response and return the result. + * + * @param ResponseInterface $response + * @param string[] $domains + * + * @return DacResult + * + * @throws ProvisionFunctionError If DAC search response is invalid + */ + protected function processResponse(ResponseInterface $response, array $domains): DacResult + { + if ($response->getStatusCode() !== 200) { + throw (new ProvisionFunctionError( + sprintf('DAC response %s: %s', $response->getStatusCode(), $response->getReasonPhrase()) + ))->withData([ + 'response_body' => Str::limit($response->getBody()->__toString(), 500), + ]); + } + + $lines = explode("\n", $response->getBody()->__toString()); + $arrayDotData = collect($lines) + ->reduce(function (array $data, string $line) { + /** @var string $line DAC result line e.g., PROPERTY[DOMAINCHECK][2]=210 Available */ + if (!$line = trim($line)) { + // skip this line + return $data; + } + + parse_str($line, $line); + + // e.g., ['PROPERTY.DOMAINCHECK.2' => '210 Available'] + return array_merge($data, Arr::dot($line)); + }, []); + + $data = $this->undot($arrayDotData); + + try { + $this->checkCode($data['CODE'] ?? $data['code']); + } catch (RuntimeException $e) { + throw (new ProvisionFunctionError($e->getMessage(), 0, null)) + ->withData([ + 'response_body' => Str::limit($response->getBody()->__toString(), 500), + ]); + } + + $dacDomains = []; + + foreach ($domains as $i => $domain) { + if (!$check = $data['PROPERTY']['DOMAINCHECK'][$i] ?? null) { + continue; + } + + [$sld, $tld] = explode('.', $domain, 2); + $tld = '.' . $tld; + + [$availabilityCode, $description] = explode(' ', $check, 2); + + if ($reason = $data['PROPERTY']['REASON'][$i] ?? null) { + $description .= '; ' . $reason; + } + + $dacDomain = DacDomain::create([ + 'domain' => $domain, + 'tld' => $tld, + 'can_register' => $availabilityCode === '210', + 'can_transfer' => $availabilityCode === '211', + 'is_premium' => Str::endsWith($check, '[PREMIUM]'), + 'description' => $description, + ]); + + $dacDomains[] = $dacDomain; + } + + return DacResult::create([ + 'domains' => $dacDomains + ]); + } + + protected function checkCode(string $code): void + { + if ($code === '200') { + return; + } + + $failureCodes = [ + '420' => 'Command failed due to server error. Server closing connection', + '421' => 'Command failed due to server error. Client should try again', + '423' => 'Command failed due to server error. Client should try again (Could not get session)', + '425' => 'Service temporarily locked; usage exceeded', + '500' => 'Invalid command name', + '503' => 'Invalid attribute name', + '504' => 'Missing required attribute', + '505' => 'Invalid attribute value syntax', + '507' => 'Invalid command format', + '520' => 'Server closing connection. Client should try opening new connection', + '521' => 'Too many sessions open. Server closing connection', + '530' => 'Authentication failed', + '531' => 'Authorization failed', + '541' => 'Invalid attribute value', + '547' => 'Invalid command sequence', + '549' => 'Command failed', + '552' => 'Object status does not allow for operation', + ]; + + $reason = $failureCodes[$code] ?? 'Something seriously broke'; + + throw new RuntimeException(sprintf('DAC Error [%s]: %s', $code, $reason)); + } + + protected function client(): Client + { + if (!isset($this->client)) { + $this->client = new Client(); + } + + return $this->client; + } + + /** + * Get a full ascii domain name from the given sld and tld. + */ + public static function getDomain(string $sld, string $tld): ?string + { + return idn_to_ascii( + sprintf('%s.%s', trim($sld, '-.'), trim($tld, '.')), + IDNA_NONTRANSITIONAL_TO_ASCII, + INTL_IDNA_VARIANT_UTS46 + ) ?: null; + } + + /** + * Un-dot an array back to multi-asoc. + * + * @param string[] $array Dot-notated array + * + * @return array Multi-assoc array + */ + protected function undot(array $array): array + { + $return = []; + + foreach ($array as $key => $value) { + data_fill($return, $key, $value); + } + + return $return; + } +} diff --git a/src/Hexonet/Data/Configuration.php b/src/Hexonet/Data/Configuration.php new file mode 100644 index 0000000..12cb24e --- /dev/null +++ b/src/Hexonet/Data/Configuration.php @@ -0,0 +1,29 @@ + ['required', 'string', 'min:3'], + 'password' => ['required', 'string', 'min:6', 'max:16'], + 'sandbox' => ['nullable', 'boolean'], + 'debug' => ['nullable', 'boolean'], + ]); + } +} diff --git a/src/Hexonet/EppExtension/EppConnection.php b/src/Hexonet/EppExtension/EppConnection.php new file mode 100644 index 0000000..530fad5 --- /dev/null +++ b/src/Hexonet/EppExtension/EppConnection.php @@ -0,0 +1,97 @@ +logger = $logger; + if (isset($logger)) { + $this->logFile = '/dev/null'; + } + } + + /** + * Writes a log message to the log file or PSR-3 logger. + * + * @inheritdoc + */ + public function writeLog($text, $action) + { + if ($this->logging && isset($this->logger)) { + $message = $text; + $message = $this->hideTextBetween($message, '', ''); + // Hide password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide new password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide domain password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide contact password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + + $this->logger->debug(sprintf("Hexonet [%s]:\n %s", $action, trim($message))); + } + + parent::writeLog($text, $action); + } +} diff --git a/src/Hexonet/EppExtension/Requests/EppCheckTransferRequest.php b/src/Hexonet/EppExtension/Requests/EppCheckTransferRequest.php new file mode 100644 index 0000000..8f95620 --- /dev/null +++ b/src/Hexonet/EppExtension/Requests/EppCheckTransferRequest.php @@ -0,0 +1,48 @@ + + + + + + + + + + */ + + // Set the extension tag for internal transfer + $extension = $this->createElement('extension'); + $keyValueExtension = $this->createElement('keyvalue:extension'); + + $command = $this->createElement('keyvalue:kv'); + $command->setAttribute('key', 'COMMAND'); + $command->setAttribute('value', 'CheckDomainTransfer'); + $keyValueExtension->appendChild($command); + + $domain = $this->createElement('keyvalue:kv'); + $domain->setAttribute('key', 'DOMAIN'); + $domain->setAttribute('value', $domainName); + $keyValueExtension->appendChild($domain); + + $extension->appendChild($keyValueExtension); + + $this->getEpp()->appendChild($extension); + } +} diff --git a/src/Hexonet/EppExtension/Requests/EppQueryTransferListRequest.php b/src/Hexonet/EppExtension/Requests/EppQueryTransferListRequest.php new file mode 100644 index 0000000..1af43ab --- /dev/null +++ b/src/Hexonet/EppExtension/Requests/EppQueryTransferListRequest.php @@ -0,0 +1,65 @@ + + + + + + + + + */ + + // Set the extension tag for internal transfer + $extensionElement = $this->createElement('extension'); + $keyValueElement = $this->createElement('keyvalue:extension'); + + $commandElement = $this->createElement('keyvalue:kv'); + $commandElement->setAttribute('key', 'COMMAND'); + $commandElement->setAttribute('value', 'QueryTransferList'); + $keyValueElement->appendChild($commandElement); + + if (!empty($domainPattern)) { + $domainElement = $this->createElement('keyvalue:kv'); + $domainElement->setAttribute('key', 'DOMAIN'); + $domainElement->setAttribute('value', $domainPattern); + $keyValueElement->appendChild($domainElement); + } + + $limitElement = $this->createElement('keyvalue:kv'); + $limitElement->setAttribute('key', 'LIMIT'); + $limitElement->setAttribute('value', (string)$limit); + $keyValueElement->appendChild($limitElement); + + if ($offset > 0) { + $offsetElement = $this->createElement('keyvalue:kv'); + $offsetElement->setAttribute('key', 'FIRST'); + $offsetElement->setAttribute('value', (string)$offset); + $keyValueElement->appendChild($offsetElement); + } + + $extensionElement->appendChild($keyValueElement); + $this->getEpp()->appendChild($extensionElement); + } +} diff --git a/src/Hexonet/EppExtension/Requests/EppTransferRequest.php b/src/Hexonet/EppExtension/Requests/EppTransferRequest.php new file mode 100644 index 0000000..e28839a --- /dev/null +++ b/src/Hexonet/EppExtension/Requests/EppTransferRequest.php @@ -0,0 +1,49 @@ +createElement('extension'); + $keyValueParent = $this->createElement('keyvalue:extension'); + + $keyValueChild = $this->createElement('keyvalue:kv'); + $keyValueChild->setAttribute('key', 'ACTION'); + $keyValueChild->setAttribute('value', 'USERTRANSFER'); + + $keyValueParent->appendChild($keyValueChild); + $extension->appendChild($keyValueParent); + + // Hexonet throws a fit if the extension comes after the clTRID tag. So let's put it immediately before it. + $commandElement = $this->getCommand(); + $commandElement->insertBefore($extension, $this->getElementsByTagName('clTRID')->item(0)); + } +} diff --git a/src/Hexonet/EppExtension/Responses/EppCheckTransferResponse.php b/src/Hexonet/EppExtension/Responses/EppCheckTransferResponse.php new file mode 100644 index 0000000..382ee04 --- /dev/null +++ b/src/Hexonet/EppExtension/Responses/EppCheckTransferResponse.php @@ -0,0 +1,52 @@ +getResultCode() == 1000 + && Str::startsWith($this->getResultReason(), '218 '); + } + + /** + * @return string[] Assoc array of extension keyvalue pairs + */ + public function getData(): array + { + $extensionNode = $this->xPath()->query('/epp:epp/epp:response/epp:extension'); + $extension = $extensionNode->item(0); + + /** @var \DomElement $child */ + foreach ($extension->childNodes as $child) { + if ($child->nodeName === 'keyvalue:extension') { + $keyValuesNode = $child; + break; + } + } + + if (!isset($keyValuesNode)) { + return []; + } + + $data = []; + + /** @var \DomElement $child */ + foreach ($keyValuesNode->childNodes as $child) { + if ($child->nodeName === 'keyvalue:kv') { + $data[$child->getAttribute('key')] = $child->getAttribute('value'); + } + } + + return $data; + } +} diff --git a/src/Hexonet/EppExtension/Responses/EppQueryTransferListResponse.php b/src/Hexonet/EppExtension/Responses/EppQueryTransferListResponse.php new file mode 100644 index 0000000..b8b10af --- /dev/null +++ b/src/Hexonet/EppExtension/Responses/EppQueryTransferListResponse.php @@ -0,0 +1,78 @@ +getData(), 'COUNT') > 0; + } + + /** + * Determine when the transfer was initiated. + */ + public function transferDate(): ?CarbonImmutable + { + if (!$this->transferExists()) { + return null; + } + + $created = Arr::get($this->getData(), 'CREATEDDATE'); + return new CarbonImmutable($created); + } + + /** + * @return string[] Assoc array of extension keyvalue pairs + */ + public function getData(): array + { + if (isset($this->extensionData)) { + return $this->extensionData; + } + + $extensionNode = $this->xPath()->query('/epp:epp/epp:response/epp:extension'); + $extension = $extensionNode->item(0); + + /** @var \DomElement $child */ + foreach ($extension->childNodes as $child) { + if ($child->nodeName === 'keyvalue:extension') { + $keyValuesNode = $child; + break; + } + } + + if (!isset($keyValuesNode)) { + return []; + } + + $data = []; + + /** @var \DomElement $child */ + foreach ($keyValuesNode->childNodes as $child) { + if ($child->nodeName === 'keyvalue:kv') { + $data[$child->getAttribute('key')] = $child->getAttribute('value'); + } + } + + return $this->extensionData = $data; + } +} diff --git a/src/Hexonet/Helper/EppHelper.php b/src/Hexonet/Helper/EppHelper.php new file mode 100644 index 0000000..60075bf --- /dev/null +++ b/src/Hexonet/Helper/EppHelper.php @@ -0,0 +1,1004 @@ + [ + 'hostname' => 'ssl://epp.ispapi.net', + 'port' => 700, + ], + 'sandbox' => [ + 'hostname' => 'ssl://epp-ote.ispapi.net', + 'port' => 700 + ] + ]; + + /** + * Authenticate and establish a connection with the Domain Provider API and login. + * + * @param Configuration $configuration + * @param array $eppConnectionData + * @return eppConnection + */ + public static function establishConnection(Configuration $configuration, LoggerInterface $logger): eppConnection + { + $connection = new eppConnection(!!$configuration->debug); + $connection->setPsrLogger($logger); + + $eppConnectionData = self::EPP_CONNECTION; + $eppConnectionData = self::validateParseEppConnectionData((bool) $configuration['sandbox'], $eppConnectionData); + + // Set connection data + $connection->setHostname($eppConnectionData['hostname']); + $connection->setPort($eppConnectionData['port']); + $connection->setUsername($configuration['username']); + $connection->setPassword($configuration['password']); + + $connection->login(); + + return $connection; + } + + /** + * After we finished with API calls, we need to close the connection. + * + * @param eppConnection $connection + * @throws eppException + */ + public static function terminateConnection(?eppConnection $connection): void + { + if (isset($connection)) { + $connection->logout(); + } + } + + /** + * Checks Domain Availability + * + * @param eppConnection $connection + * @param array $domains + * @return array The following format is do be expected: ['domain' => 'domainName', 'available' => bool, 'reason' => ?string] + * + * @throws eppException If command fails + */ + public static function checkDomains(eppConnection $connection, array $domains): array + { + // Attempt to check domain availability + $contactsRequest = new eppCheckDomainRequest($domains); + + // Process the response + /** @var eppCheckDomainResponse $response */ + $response = $connection->request($contactsRequest); + $result = []; + + // Process checks and push to result array + $checks = $response->getCheckedDomains(); + + foreach ($checks as $check) { + // Divide the domain to sld and tld + $parts = Utils::getSldTld($check['domainname']); + + $result[] = [ + 'sld' => $parts['sld'], + 'tld' => $parts['tld'], + 'domain' => $check['domainname'], + 'available' => (bool) $check['available'], + 'reason' => $check['reason'] + ]; + } + + return $result; + } + + /** + * Renew a domain for a given period + * + * @param eppConnection $connection + * @param string $domain + * @param int $period + * @return array + * + * @throws eppException If command fails + */ + public static function renewDomain(eppConnection $connection, string $domain, int $period): array + { + $domainData = new eppDomain($domain); + $info = new eppInfoDomainRequest($domainData); + + // Get Domain Info + /** @var eppInfoDomainResponse $response */ + $response = $connection->request($info); + // New Expiry Date + $expiresAt = Utils::formatDate($response->getDomainExpirationDate(), 'Y-m-d'); + + // Attempt to renew + $domainData->setPeriod($period); + $domainData->setPeriodUnit('y'); + + $renewRequest = new eppRenewRequest($domainData, $expiresAt); + /** @var eppRenewResponse $renewResponse */ + $renewResponse = $connection->request($renewRequest); + + return [ + 'domain' => $renewResponse->getDomainName(), + 'expires_at' => $renewResponse->getDomainExpirationDate(), + ]; + } + + /** + * Returns EPP Code for a given domain + * + * @param eppConnection $connection + * @param string $domainName + * @return string Epp/Auth code + * + * @throws eppException If command fails + */ + public static function getDomainEppCode(eppConnection $connection, string $domainName): string + { + $domain = new eppDomain($domainName); + $info = new eppInfoDomainRequest($domain); + + /** @var eppInfoDomainResponse $response */ + $response = $connection->request($info); + + return $response->getDomainAuthInfo(); + } + + /** + * Returns domain info + * + * @param eppConnection $connection + * @param string $domainName + * @return array + * + * @throws eppException If command fails + */ + public static function getDomainInfo(eppConnection $connection, string $domainName): array + { + $domain = new eppDomain($domainName); + $info = new eppInfoDomainRequest($domain); + + /** @var eppInfoDomainResponse */ + $response = $connection->request($info); + $registrantId = $response->getDomainRegistrant(); + $updatedAt = $response->getDomainUpdateDate(); + + return [ + 'id' => $response->getDomainId(), + 'domain' => $response->getDomainName(), + 'statuses' => $response->getDomainStatuses() ?? [], + 'registrant' => $registrantId ? self::getContactInfo($connection, $registrantId) : null, + // 'adminContactId' => $response->getDomainContact(eppContactHandle::CONTACT_TYPE_ADMIN), + // 'billingContactId' => $response->getDomainContact(eppContactHandle::CONTACT_TYPE_BILLING), + // 'techContactId' => $response->getDomainContact(eppContactHandle::CONTACT_TYPE_TECH), + 'ns' => self::parseNameServers($response->getDomainNameservers() ?? []), + 'created_at' => Utils::formatDate($response->getDomainCreateDate()), + 'updated_at' => Utils::formatDate($updatedAt ?: $response->getDomainCreateDate()), + 'expires_at' => Utils::formatDate($response->getDomainExpirationDate()) + ]; + } + + /** + * Updating a contact + * + * @param eppConnection $connection + * @param string $contactId + * @param string $email + * @param string|null $telephone + * @param string $name + * @param string|null $organization + * @param string|null $address + * @param string|null $postcode + * @param string|null $city + * @param string|null $countryCode + * @param string|null $contactType + * @param string|null $password + * @return DomainContactInfo + * + * @throws eppException If command fails + */ + public static function updateDomainContact( + eppConnection $connection, + string $contactId, + string $email, + ?string $telephone, + string $name, + ?string $organization = null, + ?string $address, + ?string $postcode, + ?string $city, + ?string $state, + ?string $countryCode, + ?string $contactType = null, + ?string $password = null + ): DomainContactInfo { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + } + + if ($countryCode) { + $countryCode = Utils::normalizeCountryCode($countryCode); + } + + // Build the update query + $postalInfo = new eppContactPostalInfo($name, $city, $countryCode, $organization, $address, $state, $postcode); + $contactInfo = new eppContact($postalInfo, $email, $telephone); + + if (!is_null($contactType)) { + $contactInfo->setType($contactType); + } + + if (!is_null($password)) { + $contactInfo->setPassword($password); + } + + $updateQuery = new eppContact($postalInfo, $email, $telephone); + $updateRequest = new eppUpdateContactRequest(new eppContactHandle($contactId), null, null, $updateQuery); + + /** @var eppUpdateContactResponse $response */ + $response = $connection->request($updateRequest); + + return DomainContactInfo::create([ + 'contact_id' => $contactId, + 'name' => $name, + 'email' => $email, + 'phone' => $telephone, + 'organisation' => $organization, + 'address1' => $address, + 'city' => $city, + 'state' => $state, + 'postcode' => $postcode, + 'country_code' => $countryCode, + 'type' => $contactType + ]); + } + + /** + * Returns domain contact id + * + * @param eppConnection $connection + * @param string $domainName + * @param string $contactType One of: reg, admin, billing, tech + * @return string|null Contact id + * @throws eppException + */ + public static function getDomainContactId( + eppConnection $connection, + string $domainName, + string $contactType = eppContactHandle::CONTACT_TYPE_REGISTRANT + ): ?string { + $validContactTypes = [ + eppContactHandle::CONTACT_TYPE_REGISTRANT, + eppContactHandle::CONTACT_TYPE_ADMIN, + eppContactHandle::CONTACT_TYPE_TECH, + eppContactHandle::CONTACT_TYPE_BILLING, + ]; + + if (!in_array($contactType, $validContactTypes)) { + throw new LogicException(sprintf('Invalid contact type %s', $contactType)); + } + + $domain = new eppDomain($domainName); + $info = new eppInfoDomainRequest($domain); + + /** @var eppInfoDomainResponse */ + $response = $connection->request($info); + + return $contactType === eppContactHandle::CONTACT_TYPE_REGISTRANT + ? $response->getDomainRegistrant() + : $response->getDomainContact($contactType); + } + + /** + * Create Contact for a domain + * + * @param eppConnection $connection + * @param string $email + * @param string|null $telephone + * @param string $name + * @param string|null $organization + * @param string|null $address + * @param string|null $postcode + * @param string|null $city + * @param string|null $state + * @param string|null $countryCode + * @param string|null $contactType + * @param string|null $password + * @return DomainContactInfo + * + * @throws eppException If command fails + */ + public static function createContact( + eppConnection $connection, + string $email, + ?string $telephone, + string $name, + ?string $organization = null, + ?string $address, + ?string $postcode, + ?string $city, + ?string $state, + ?string $countryCode, + ?string $contactType = null, + ?string $password = null + ): DomainContactInfo { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + } + + if ($countryCode) { + $countryCode = Utils::normalizeCountryCode($countryCode); + } + + $postalInfo = new eppContactPostalInfo($name, $city, $countryCode, $organization, $address, $state, $postcode); + $contactInfo = new eppContact($postalInfo, $email, $telephone); + + if (!is_null($contactType)) { + $contactInfo->setType($contactType); + } + + if (!is_null($password)) { + $contactInfo->setPassword($password); + } + + $contact = new eppCreateContactRequest($contactInfo); + + // Include more details in the response + /** @var eppCreateContactResponse $response */ + $response = $connection->request($contact); + + return DomainContactInfo::create([ + 'contact_id' => $response->getContactId(), + 'name' => $name, + 'email' => $email, + 'phone' => $telephone, + 'organisation' => $organization, + 'address1' => $address, + 'city' => $city, + 'postcode' => $postcode, + 'country_code' => $countryCode, + 'type' => $contactType, + ]); + } + + /** + * Set or update the given contact on the given domain name. + * + * @param \Upmind\ProvisionProviders\DomainNames\Hexonet\EppExtension\EppConnection $connection + * @param string $domainName + * @param string $contactType One of: reg, admin, billing, tech + * @param string $contactId + * + * @return \Metaregistrar\EPP\eppUpdateResponse + * + * @throws eppException If command fails + */ + public static function setDomainContact( + eppConnection $connection, + string $domainName, + string $contactType, + string $contactId + ): eppUpdateResponse { + switch ($contactType) { + case eppContactHandle::CONTACT_TYPE_REGISTRANT: + $registrantId = $contactId; + break; + case eppContactHandle::CONTACT_TYPE_ADMIN: + $adminId = $contactId; + break; + case eppContactHandle::CONTACT_TYPE_BILLING: + $billingId = $contactId; + break; + case eppContactHandle::CONTACT_TYPE_TECH: + $techId = $contactId; + break; + } + + return self::updateDomain( + $connection, + $domainName, + null, + $registrantId ?? null, + $adminId ?? null, + $billingId ?? null, + $techId ?? null + ); + } + + /** + * Query list of existing transfer-IN requests. + */ + public static function queryTransferList(eppConnection $connection, string $domain): EppQueryTransferListResponse + { + $transferQueryRequest = new EppQueryTransferListRequest($domain); + /** @var EppQueryTransferListResponse $transferQueryResponse */ + $transferQueryResponse = $connection->request($transferQueryRequest); + + return $transferQueryResponse; + } + + /** + * Requests and tries to approve domain transfer + * + * This method will handle the request transfer and then will try to automatically approve the request. + * + * @param eppConnection $connection + * @param string $domain + * @param array $nameServers + * @param string|null $registrantId + * @param string|null $adminContactId + * @param string|null $billingContactId + * @param string|null $techContactId + * @param string|null $eppCode + * @param int $renewYears How many years to renew the domain for upon successful transfer + * @return eppTransferResponse + * + * @throws eppException If command fails + * @throws ProvisionFunctionError If domain is not transferrable + */ + public static function transferRequest( + eppConnection $connection, + string $domain, + array $nameServers, + ?string $registrantId = null, + ?string $adminContactId = null, + ?string $billingContactId = null, + ?string $techContactId = null, + ?string $eppCode = null, + int $renewYears = 1 + ): eppTransferResponse { + $transferCheck = self::checkTransfer($connection, $domain); + $checkData = $transferCheck->getData(); + + if (!$transferCheck->isAvailable()) { + return self::errorResult($transferCheck->getResultReason(), $checkData); + } + + if (!empty($checkData['TRANSFERLOCK'])) { + return self::errorResult('Domain is currently transfer-locked', $checkData); + } + + // Get Domain Info + $domainInfo = new eppDomain($domain); + + // Set EPP Code + if (isset($eppCode)) { + $domainInfo->setAuthorisationCode($eppCode); + } + + $domainInfo->setPeriod($renewYears); + $domainInfo->setPeriodUnit('y'); + + // Add Registrant Contact + if (!is_null($registrantId)) { + $domainInfo->setRegistrant(new eppContactHandle($registrantId)); + } + + // Add Contacts + if (!is_null($adminContactId)) { + $domainInfo->addContact(new eppContactHandle($adminContactId, eppContactHandle::CONTACT_TYPE_ADMIN)); + } + + if (!is_null($billingContactId)) { + $domainInfo->addContact(new eppContactHandle($billingContactId, eppContactHandle::CONTACT_TYPE_TECH)); + } + + if (!is_null($techContactId)) { + $domainInfo->addContact(new eppContactHandle($techContactId, eppContactHandle::CONTACT_TYPE_BILLING)); + } + + // // Set Name Servers + // self::validateNameServers($nameServers); + + // // Add Name Servers + // $domainInfo = self::addNameServers($connection, $domainInfo, $nameServers); + + // // Check if we have the name servers yet + // if (count($domainInfo->getHosts()) < 1) { + // return ['error' => 'We were unable to add name servers for the domain!']; + // } + + // Using our custom transfer request here in order to support Hexonet's USERTRANSFER for internal transfers + $transferRequest = new eppTransferRequest(eppTransferRequest::OPERATION_REQUEST, $domainInfo); + if (!empty($checkData['USERTRANSFERREQUIRED'])) { + $transferRequest->addUserTransferAction(); + } + + // Process Response + /** @var eppTransferResponse */ + return $connection->request($transferRequest); + } + + public static function checkTransfer(eppConnection $connection, string $domain): EppCheckTransferResponse + { + /** @var EppCheckTransferResponse */ + return $connection->request(new EppCheckTransferRequest($domain)); + } + + /** + * @param eppConnection $connection + * @param string $domainName + * @param int $period Registration period in years + * @param string $registrantId + * @param string $adminContactId + * @param string $billingContactId + * @param string $techContactId + * @param array $nameServers + * @return array + * + * @throws eppException If command fails + * @throws ProvisionFunctionError If NS cannot be added + */ + public static function createDomain( + eppConnection $connection, + string $domainName, + int $period, + string $registrantId, + string $adminContactId, + string $billingContactId, + string $techContactId, + array $nameServers + ): array { + // Validate NameServers + self::validateNameServers($nameServers); + + $domain = new eppDomain($domainName, $registrantId, [ + new eppContactHandle($adminContactId, 'admin'), + new eppContactHandle($techContactId, 'tech'), + new eppContactHandle($billingContactId, 'billing') + ]); + + $domain->setRegistrant(new eppContactHandle($registrantId)); + $domain->setAuthorisationCode(self::generateValidAuthCode()); + + // Add Name Servers + $domain = self::addNameServers($connection, $domain, $nameServers); + + // Check if we have the name servers yet + if (count($domain->getHosts()) < 1) { + return self::errorResult('We were unable to add name servers for the domain!', [ + 'domain' => $domainName, + 'nameservers' => $nameServers + ]); + } + + // Set Domain Period + $domain->setPeriod($period); + $domain->setPeriodUnit('y'); + + // Create the domain + $create = new eppCreateDomainRequest($domain); + + /** @var eppCreateDomainResponse */ + $response = $connection->request($create); + + return [ + 'domain' => $response->getDomainName(), + 'created_at' => Utils::formatDate($response->getDomainCreateDate()), + 'expires_at' => Utils::formatDate($response->getDomainExpirationDate()) + ]; + } + + /** + * Add and check name servers to a domain + * + * @param eppConnection $connection + * @param eppDomain $domain + * @param array $nameServers + * @return eppDomain + * @throws eppException + */ + private static function addNameServers(eppConnection $connection, eppDomain $domain, array $nameServers): eppDomain + { + // Check our name servers + $nameServers = collect($nameServers); + $availableHosts = self::checkHosts($connection, $nameServers); + + foreach ($availableHosts as $nameServer => $available) { + $nameServerData = $nameServers->where('host', $nameServer)->first(); + + // In case this host is not known and it's available to be created - attempt to create it. If it's unavailable for creation - just add it to the domain + if ($available) { + if (!self::createHost($connection, $nameServerData['host'], $nameServerData['ip'] ?? null)) { + // Problem while creating the host. Continue with the next + continue; + } + } + + // All ok, add it to the domain + $domain->addHost(new eppHost($nameServerData['host'], $nameServerData['ip'] ?? null, null)); + } + + return $domain; + } + + /** + * Updates details about a domain given. + * With this method we can update: + * - name servers + * - admin contact + * - billing contact + * - tech contact + * - registrant contact + * + * Contact IDs should be passed as identifier strings. + * + * @param eppConnection $connection + * @param string $domainName + * @param array|null $nameServers + * @param string|null $registrantContactId + * @param string|null $adminContactId + * @param string|null $billingContactId + * @param string|null $techContactId + * @return eppUpdateResponse + * + * @throws eppException If command fails + */ + public static function updateDomain( + eppConnection $connection, + string $domainName, + ?array $nameServers = null, + ?string $registrantContactId = null, + ?string $adminContactId = null, + ?string $billingContactId = null, + ?string $techContactId = null + ): eppUpdateResponse { + // Attempt to get the domain + $domain = new eppDomain($domainName); + $domainInfo = new eppInfoDomainRequest($domain); + + // The data in the remove array should be deleted from the domain first and then added again + $removeData = new eppDomain($domainName); + $updateData = new eppDomain($domainName); + $addData = new eppDomain($domainName); + + // Check the results and the details that we have to update + /** @var eppInfoDomainResponse */ + $response = $connection->request($domainInfo); + + // Update Name Servers + if (!is_null($nameServers)) { + // Get the current name servers + $currentNameServers = $response->getDomainNameservers(); + + // Remove the current name servers + if (is_array(($currentNameServers))) { + foreach ($currentNameServers as $currentNameServer) { + $removeData->addHost(new eppHost($currentNameServer->getHostname())); + } + } + + // Set new name servers + $addData = self::addNameServers($connection, $addData, $nameServers); + } + + // Update Main Contact + if (!is_null($registrantContactId)) { + $updateData->setRegistrant(new eppContactHandle($registrantContactId)); + } + + // Update Admin Contact + if (!is_null($adminContactId)) { + // Get the admin contact ID + $currentAdmin = $response->getDomainContact(eppContactHandle::CONTACT_TYPE_ADMIN); + + // In case we're providing a brand new admin contact, remove the old and add the new one + if ($currentAdmin != $adminContactId) { + if (!empty($currentAdmin)) { + $removeData->addContact(new eppContactHandle($currentAdmin, eppContactHandle::CONTACT_TYPE_ADMIN)); + } + + $addData->addContact(new eppContactHandle($adminContactId, eppContactHandle::CONTACT_TYPE_ADMIN)); + } + } + + // Update Billing Contact + if (!is_null($billingContactId)) { + // Get the billing contact ID + $currentBilling = $response->getDomainContact(eppContactHandle::CONTACT_TYPE_BILLING); + + // In case we're providing a brand new billing contact, remove the old and add the new one + if ($currentBilling != $billingContactId) { + if (!empty($currentBilling)) { + $removeData->addContact(new eppContactHandle($currentBilling, eppContactHandle::CONTACT_TYPE_BILLING)); + } + + $addData->addContact(new eppContactHandle($billingContactId, eppContactHandle::CONTACT_TYPE_BILLING)); + } + } + + // Update Tech Contact + if (!is_null($techContactId)) { + // Get the billing contact ID + $currentTech = $response->getDomainContact(eppContactHandle::CONTACT_TYPE_TECH); + + // In case we're providing a brand new billing contact, remove the old and add the new one + if ($currentTech != $techContactId) { + if (!empty($currentTech)) { + $removeData->addContact(new eppContactHandle($currentTech, eppContactHandle::CONTACT_TYPE_TECH)); + } + + $addData->addContact(new eppContactHandle($techContactId, eppContactHandle::CONTACT_TYPE_TECH)); + } + } + + // Save all the changes + $update = new eppUpdateDomainRequest($domain, $addData, $removeData, $updateData); + + /** @var eppUpdateResponse */ + return $connection->request($update); + } + + /** + * Checks for valid contact by ID + * + * @param eppConnection $connection + * @param string $contactId + * @return bool + */ + public static function isValidContactId(eppConnection $connection, string $contactId): bool + { + try { + self::getContactInfo($connection, $contactId); + return true; + } catch (eppException $e) { + return false; + } + } + + /** + * Get Contact Info + * + * @param eppConnection $connection + * @param string $contactId + * @return DomainContactInfo + * + * @throws eppException If command fails E.g., if contact id is invalid + */ + public static function getContactInfo(eppConnection $connection, string $contactId): DomainContactInfo + { + $request = new eppInfoContactRequest(new eppContactHandle($contactId), false); + /** @var eppInfoContactResponse */ + $response = $connection->request($request); + + return DomainContactInfo::create([ + 'contact_id' => $contactId, + 'name' => $response->getContactName(), + 'email' => $response->getContactEmail(), + 'phone' => $response->getContactVoice(), + 'organisation' => $response->getContactCompanyname(), + 'address1' => $response->getContactStreet(), + 'city' => $response->getContactCity(), + 'state' => $response->getContactProvince(), + 'postcode' => $response->getContactZipcode(), + 'country_code' => $response->getContactCountrycode(), + 'type' => $response->getContact()->getType(), + ]); + } + + /** + * Return a normalized array with names servers ['host' => 'hostname', 'ip' => 'ipAddress'] + * + * @param eppHost[] $nameServers Array with eppHost objects + * @return array + */ + private static function parseNameServers(array $nameServers): array + { + $result = []; + + if (count($nameServers) > 0) { + foreach ($nameServers as $i => $ns) { + $result['ns' . ($i + 1)] = [ + 'host' => $ns->getHostName(), + 'ip' => $ns->getIpAddresses() + ]; + } + } + + return $result; + } + + /** + * @param eppConnection $connection + * @param Collection $hosts + * @return array|null + */ + public static function checkHosts(eppConnection $connection, Collection $hosts): ?array + { + try { + $checkHost = []; + + $hosts->each(function ($host) use (&$checkHost) { + $checkHost[] = new eppHost($host['host'], $host['ip'] ?? null); + }); + + $check = new eppCheckRequest($checkHost); + + if ($response = $connection->request($check)) { + $checks = $response->getCheckedHosts(); + + return $checks; + } + + return null; + } catch (eppException $e) { + return null; + } + } + + /** + * @param eppConnection $connection + * @param string $host + * @param string|null $ip + * @return bool + */ + public static function createHost(eppConnection $connection, string $host, string $ip = null): bool + { + try { + $create = new eppCreateHostRequest(new eppHost($host, $ip)); + + if ($response = $connection->request($create)) { + return true; + } + + return false; + } catch (eppException $e) { + return false; + } + } + + /** + * Make sure that we have the details in the right format. + * + * @param bool $sandbox + * @param array $eppConnectionData + * @return array + */ + public static function validateParseEppConnectionData(bool $sandbox, array $eppConnectionData): array + { + $environment = ($sandbox) ? 'sandbox' : 'live'; + + if (!isset($eppConnectionData[$environment])) { + throw new RuntimeException('We are unable to find details for ' . $environment . ' connection to the EPP environment!'); + } + + if (!isset($eppConnectionData[$environment]['hostname'])) { + throw new RuntimeException('We are unable to find hostname for ' . $environment . ' connection to the EPP environment!'); + } + + if (!isset($eppConnectionData[$environment]['port'])) { + throw new RuntimeException('We are unable to find port for ' . $environment . ' connection to the EPP environment!'); + } + + return $eppConnectionData[$environment]; + } + + /** + * Validates the provider name servers. Just.In.Case + * + * @param array $nameServers + * @throws ProvisionFunctionError + */ + public static function validateNameServers(array $nameServers): void + { + foreach ($nameServers as $nameServer) { + if (!isset($nameServer['host'])) { + throw (new ProvisionFunctionError('No valid host for name server found in name server configuration!')) + ->withData(['nameservers' => $nameServers]); + } + + // if (!is_null($nameServer['ip']) && !filter_var($nameServer['ip'], FILTER_VALIDATE_IP)) { + // throw new ProvisionFunctionError('No valid IP Address for name server found in name server configuration!'); + // } + } + } + + /** + * Throws a ProvisionFunctionError to interrupt execution and generate an + * error result. + * + * @param string $message Error result message + * @param array $data Error data + * @param array $debug Error debug + * @param Throwable|null $previous Encountered exception + * + * @throws ProvisionFunctionError + */ + public static function errorResult($message, $data = [], $debug = [], ?Throwable $previous = null): void + { + throw (new ProvisionFunctionError($message, 0, $previous)) + ->withData($data) + ->withDebug($debug); + } + + /** + * Generates a random auth code containing lowercase letters, uppercase letters, numbers and special characters. + * + * @return string + */ + private static function generateValidAuthCode(int $length = 12): string + { + return Helper::generateStrictPassword($length, true, true, true, '!@#$%^*_'); + } +} diff --git a/src/Hexonet/Helper/HexonetApi.php b/src/Hexonet/Helper/HexonetApi.php new file mode 100644 index 0000000..e1580cc --- /dev/null +++ b/src/Hexonet/Helper/HexonetApi.php @@ -0,0 +1,307 @@ +client = self::establishConnection($configuration, $logger); + } + + /** + * Authenticate and establish a connection with the Domain Provider API and login. + * + * @throws ProvisionFunctionError + */ + protected function establishConnection(Configuration $configuration, LoggerInterface $logger): HexonetApiClient + { + // Init Client + $client = new HexonetApiClient(); + $client->setCredentials($configuration->username, $configuration->password); + + // Set Environment + if ($configuration->sandbox) { + $client->useOTESystem(); + $client->setURL('https://api-ote.ispapi.net/api/call.cgi'); + } + + // Set Logging and Logger Handler + if ($configuration->debug) { + $client->enableDebugMode(true); + $client->setCustomLogger(new HexonetLogger($logger)); + } + + // Login + $loginRequest = $client->login(); + + // Process response + if (!$loginRequest->isSuccess()) { + throw ProvisionFunctionError::create('Unable to authenticate connection with provider API') + ->withData([ + 'description' => $loginRequest->getDescription(), + 'response' => $loginRequest->getHash(), + ]); + } + + return $client; + } + + /** + * Run a command against the API + * + * @param string $command + * @param array $parameters + * @return HexonetResponse + */ + public function runCommand(string $command, array $parameters = []): HexonetResponse + { + // Build the Payload + $requestData = [ + 'COMMAND' => $command + ]; + + // Add additional parameters to the request + if (count($parameters) > 0) { + foreach ($parameters as $k => $v) { + $requestData[$k] = $v; + } + } + + // Send Payload + $response = $this->client->request($requestData); + + // Process response + if (!$response->isSuccess()) { + throw ProvisionFunctionError::create(sprintf('Provider API Error: %s', $response->getDescription())) + ->withData([ + 'response' => $response->getHash(), + ]); + } + + return $response; + } + + /** + * After we finished with API calls, we need to close the connection. + */ + public function terminateConnection(): void + { + $logoutRequest = $this->client->logout(); + + // if (!$logoutRequest->isSuccess()) { + // throw new RuntimeException('There was a problem while terminating the Hexonet HTTPS API Session!'); + // } + } + + public function markDomainRenewalAsPaid(string $domain): array + { + return $this->runCommand('PayDomainRenewal', [ + 'domain' => $domain, + ])->getHash(); + } + + public function statusDomain(string $domain): array + { + $result = $this->runCommand('StatusDomain', [ + 'domain' => $domain, + ])->getHash(); + + return $result['PROPERTY']; + } + + /** + * @param string $domain + * @param Nameserver[] $nameservers + * + * @return Nameserver[] New nameservers + */ + public function setNameservers(string $domain, array $nameservers): array + { + $nameservers = array_values($nameservers); + + $params = [ + 'domain' => $domain, + ]; + + foreach ($nameservers as $i => $nameserver) { + $params['nameserver' . $i] = $nameserver->host; + } + + $result = $this->runCommand('ModifyDomain', $params); // doesnt actually confirm the new NS + + return $nameservers; + } + + public function updateRegistrant( + string $domain, + ContactParams $contact + ): ContactResult { + $nameParts = explode(' ', $contact->name ?: $contact->organisation); + + $params = [ + 'domain' => $domain, + 'ownercontact0' => [ + 'firstname' => array_shift($nameParts), + 'lastname' => implode(' ', $nameParts), + 'organization' => $contact->organisation, + 'street' => $contact->address1, + 'city' => $contact->city, + 'state' => $contact->has('state') ? $contact->state : null, + 'zip' => $contact->postcode, + 'country' => Utils::normalizeCountryCode($contact->country_code), + 'phone' => $contact->phone, + // 'fax' => , + 'email' => $contact->email, + ], + ]; + + $result = $this->runCommand('ModifyDomain', $params); // nothing useful in the result + + return ContactResult::create($contact); + + throw ProvisionFunctionError::create('update registrant')->withData(['result' => $result->getHash()]); + } + + /** + * Returns an associative array with contacts + * + * @param array $filters + * @return array + */ + public function getContacts(array $filters = []): array + { + // TODO: Apply filters (by X criteria, limit (pagination) etc) + + // Get Raw Response + $rawContacts = $this->runCommand('QueryContactList', [ + 'wide' => 1 + ])->getListHash(); + + // Return the processed contacts data + $processed = []; + + if (isset($rawContacts['LIST']) && is_array($rawContacts['LIST'])) { + foreach ($rawContacts['LIST'] as $rawContact) { + $processed[] = [ + 'contact_id' => $rawContact['CONTACT'], + 'name' => $rawContact['CONTACTFIRSTNAME'] . ' ' . $rawContact['CONTACTLASTNAME'], + 'email' => $rawContact['CONTACTEMAIL'], + 'phone' => $rawContact['CONTACTPHONE'], + 'company' => $rawContact['CONTACTORGANIZATION'], + 'address1' => $rawContact['CONTACTSTREET'], + 'city' => $rawContact['CONTACTCITY'], + 'postcode' => $rawContact['CONTACTZIP'], + 'country_code' => $rawContact['CONTACTCOUNTRY'], + ]; + } + } + + return $processed; + } + + /** + * Unlocks/Locks a domain for transfer + * + * @param string $domain + * @param bool $lock + * @return array + */ + public function setTransferLock(string $domain, bool $lock): array + { + // Get Raw Response + $additionalParams = [ + 'domain' => $domain, + 'transferlock' => (int) $lock + ]; + + $toggle = $this->runCommand('ModifyDomain', $additionalParams); + + return $additionalParams; + } + + /** + * Sets renewal mode to autoexpire or autorenew + * + * @param string $domain + * @param bool $autoRenew [true - autorenew; false - autoexpire] + * @return array + */ + public function setRenewalMode(string $domain, bool $autoRenew): array + { + // Get Raw Response + $additionalParams = [ + 'domain' => $domain, + 'renewalMode' => ($autoRenew) ? 'AUTORENEW' : 'AUTOEXPIRE' + ]; + + $setRenewal = $this->runCommand('SetDomainRenewalMode', $additionalParams); + + return $additionalParams; + } + + /** + * Returns domain list from the account + * + * @param array|null $filters + * @return array + */ + public function listDomains(?array $filters = []): array + { + // TODO: Apply filters (by X criteria, limit (pagination) etc) + + // Get Raw Response + $rawDomains = $this->runCommand('QueryDomainList', [ + 'wide' => 1, + ])->getListHash(); + + // Process the response, adding the minimum-required info for a domain + $processed = []; + + if (isset($rawDomains['LIST']) && is_array($rawDomains['LIST'])) { + foreach ($rawDomains['LIST'] as $rawDomain) { + // Get TLD and SLD + $parts = Utils::getSldTld($rawDomain['DOMAIN']); + + $processed[] = [ + 'sld' => $parts['sld'], + 'tld' => $parts['tld'], + 'domain' => $rawDomain['DOMAIN'], + 'created_at' => $rawDomain['DOMAINCREATEDDATE'], + 'expires_at' => Utils::formatDate($rawDomain['DOMAINEXPIRATIONDATE']), + ]; + } + } + + return $processed; + } +} diff --git a/src/Hexonet/Helper/HexonetLogger.php b/src/Hexonet/Helper/HexonetLogger.php new file mode 100644 index 0000000..0c4d97b --- /dev/null +++ b/src/Hexonet/Helper/HexonetLogger.php @@ -0,0 +1,46 @@ +__(_____)____/ + * + * Class HexonetLogger + * @package Upmind\ProvisionProviders\DomainNames\Hexonet\Helper + */ +class HexonetLogger extends BaseHexonetLogger +{ + protected LoggerInterface $logger; + + public function __construct(LoggerInterface $logger) + { + $this->logger = $logger; + } + + public function log(string $post, BaseHexonetResponse $r, ?string $error = null): void + { + // Compile the Message + $message = implode("\n", [ + '[REQUEST]', + $r->getCommandPlain(), + '[RAW REQUEST]', + $post, + $error ? "\n[ERROR]\n" . $error : '', + $r->getPlain() + ]); + + // Log the message + $this->logger->debug("Hexonet API: \n" . $message); + } +} diff --git a/src/Hexonet/Provider.php b/src/Hexonet/Provider.php new file mode 100644 index 0000000..ac4e3ed --- /dev/null +++ b/src/Hexonet/Provider.php @@ -0,0 +1,899 @@ + 'ns1.ispapi.net', // Alternatively: ns1.hexonet.net, + 'ip' => '194.50.187.134' + ], + [ + 'host' => 'ns2.ispapi.net', // Alternatively: ns2.hexonet.net, + 'ip' => '194.0.182.1' + ], + [ + 'host' => 'ns3.ispapi.net', // Alternatively: ns3.hexonet.net, + 'ip' => '193.227.117.124' + ] + ]; + + /** + * Max count of name servers that we can expect in a request + */ + private const MAX_CUSTOM_NAMESERVERS = 5; + + // Contact types. + public const CONTACT_LOC = 'loc'; + public const CONTACT_INT = 'int'; + public const CONTACT_AUTO = 'auto'; + + public function __construct(Configuration $configuration) + { + // dont connect straight away - wait until function call for any connection errors to surface + $this->configuration = $configuration; + } + + public function __destruct() + { + $this->disconnect(); + } + + /** + * Returns general info about the package + * + * @return AboutData + */ + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('Hexonet') + ->setDescription('Register, transfer, renew and manage Hexonet domains') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/hexonet-logo@2x.png'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + $dac = new Dac($this->configuration, new Client([ + 'handler' => $this->getGuzzleHandlerStack(!!$this->configuration->sandbox), + ])); + + return $dac->search($params->sld, $params->tlds); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + /** + * Domain Registration + * + * @param RegisterDomainParams $params + * @return ResultData + */ + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + // Establish connection to Hexonet API via EPP protocol + try { + $connection = $this->connect(); + + // Loop Over the Contact Types and create them one by one + if (Arr::has($params, 'registrant.id')) { + $registrantId = Arr::get($params, 'registrant.id'); + + // Validate the contactId + if (!EppHelper::isValidContactId($connection, $registrantId)) { + throw $this->errorResult("Invalid registrant ID provided!", $params); + } + } else { + // Try to set contact type + if ( + Arr::has($params, 'registrant.register.type') + && in_array(Arr::get($params, 'registrant.register.type'), [ + self::CONTACT_LOC, + self::CONTACT_INT, + self::CONTACT_AUTO + ]) + ) { + $contactType = Arr::get($params, 'registrant.register.type'); + } else { + $contactType = self::CONTACT_AUTO; + } + + // Create contact instance and get the ID as a string + $registrant = EppHelper::createContact( + $connection, + Arr::get($params, 'registrant.register.email'), + Arr::get($params, 'registrant.register.phone'), + Arr::get($params, 'registrant.register.name', Arr::get($params, 'registrant.register.organisation')), + Arr::get($params, 'registrant.register.organisation', Arr::get($params, 'registrant.register.name')), + Arr::get($params, 'registrant.register.address1'), + Arr::get($params, 'registrant.register.postcode'), + Arr::get($params, 'registrant.register.city'), + Arr::get($params, 'registrant.register.state'), + Arr::get($params, 'registrant.register.country_code'), + $contactType + ); + + $registrantId = $registrant->contact_id; + } + + if (!$adminId = $params->admin->id) { + // Try to set contact type + if ( + Arr::has($params, 'admin.register.type') + && in_array(Arr::get($params, 'admin.register.type'), [ + self::CONTACT_LOC, + self::CONTACT_INT, + self::CONTACT_AUTO + ]) + ) { + $contactType = Arr::get($params, 'admin.register.type'); + } else { + $contactType = self::CONTACT_AUTO; + } + + // Create contact instance and get the ID as a string + $admin = EppHelper::createContact( + $connection, + Arr::get($params, 'admin.register.email'), + Arr::get($params, 'admin.register.phone'), + Arr::get($params, 'admin.register.name', Arr::get($params, 'admin.register.organisation')), + Arr::get($params, 'admin.register.organisation', Arr::get($params, 'admin.register.name')), + Arr::get($params, 'admin.register.address1'), + Arr::get($params, 'admin.register.postcode'), + Arr::get($params, 'admin.register.city'), + Arr::get($params, 'admin.register.state'), + Arr::get($params, 'admin.register.country_code'), + $contactType + ); + + $adminId = $admin->contact_id; + } + + if (!$billingId = $params->billing->id) { + // Try to set contact type + if ( + Arr::has($params, 'billing.register.type') + && in_array(Arr::get($params, 'billing.register.type'), [ + self::CONTACT_LOC, + self::CONTACT_INT, + self::CONTACT_AUTO + ]) + ) { + $contactType = Arr::get($params, 'billing.register.type'); + } else { + $contactType = self::CONTACT_AUTO; + } + + // Create contact instance and get the ID as a string + $billing = EppHelper::createContact( + $connection, + Arr::get($params, 'billing.register.email'), + Arr::get($params, 'billing.register.phone'), + Arr::get($params, 'billing.register.name', Arr::get($params, 'billing.register.organisation')), + Arr::get($params, 'billing.register.organisation', Arr::get($params, 'billing.register.name')), + Arr::get($params, 'billing.register.address1'), + Arr::get($params, 'billing.register.postcode'), + Arr::get($params, 'billing.register.city'), + Arr::get($params, 'billing.register.state'), + Arr::get($params, 'billing.register.country_code'), + $contactType + ); + + $billingId = $billing->contact_id; + } + + if (!$techId = $params->tech->id) { + // Try to set contact type + if ( + Arr::has($params, 'tech.register.type') + && in_array(Arr::get($params, 'tech.register.type'), [ + self::CONTACT_LOC, + self::CONTACT_INT, + self::CONTACT_AUTO + ]) + ) { + $contactType = Arr::get($params, 'tech.register.type'); + } else { + $contactType = self::CONTACT_AUTO; + } + + // Create contact instance and get the ID as a string + $tech = EppHelper::createContact( + $connection, + Arr::get($params, 'tech.register.email'), + Arr::get($params, 'tech.register.phone'), + Arr::get($params, 'tech.register.name', Arr::get($params, 'tech.register.organisation')), + Arr::get($params, 'tech.register.organisation', Arr::get($params, 'tech.register.name')), + Arr::get($params, 'tech.register.address1'), + Arr::get($params, 'tech.register.postcode'), + Arr::get($params, 'tech.register.city'), + Arr::get($params, 'tech.register.state'), + Arr::get($params, 'tech.register.country_code'), + $contactType + ); + + $techId = $tech->contact_id; + } + + // Determine which name servers to use + $ownNameServers = null; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $ownNameServers[] = Arr::get($params, 'nameservers.ns' . $i); + } + } + + // Use the default name servers in case we didn't provide our own + if (!is_null($ownNameServers)) { + $nameServers = $ownNameServers; + } else { + $nameServers = self::NAMESERVERS; + } + + // Proceed to domain registration + $domainRegistration = EppHelper::createDomain( + $connection, + $domain, + intval(Arr::get($params, 'renew_years', 1)), + $registrantId, + $adminId, + $billingId, + $techId, + $nameServers + ); + + return $this->_getInfo($domain, sprintf('Domain %s was registered successfully!', $domain)); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Transfer a domain (submit transfer request) + * + * @param TransferParams $params + * @return ResultData + */ + public function transfer(TransferParams $params): DomainResult + { + // Get the domain name + $domain = Utils::getDomain($params->sld, $params->tld); + $eppCode = $params->epp_code; + + try { + // Establish the connection + $connection = $this->connect(); + + try { + // if domain is active, return success + return $this->_getInfo($domain, 'Domain exists in registrar account'); + } catch (eppException $e) { + // domain is not active - proceed with initiating a transfer below... + } + + $transferQuery = EppHelper::queryTransferList($connection, $domain); + if ($transferQuery->transferExists()) { + throw $this->errorResult( + sprintf('Transfer already initiated %s', $transferQuery->transferDate()->diffForHumans()), + $transferQuery->getData() + ); + } + + /** + * The bellow is commented out, because we'll be leaving registrar/contact changes for later when the customer requests it. + */ + // Handle the contacts for the transfer + /*if (Arr::has($params, 'registrant.id')) { + $registrantId = Arr::get($params, 'registrant.id'); + + // Get Data + $registrant = EppHelper::getContactInfo($connection, $registrantId); + + // Check if valid + if (is_null($registrant)) { + throw $this->errorResult("Invalid registrant ID provided!", $params); + } + } else { + // Try to set contact type + if (Arr::has($params, 'registrant.register.type') + && in_array(Arr::get($params, 'registrant.register.type'), [ + self::CONTACT_LOC, + self::CONTACT_INT, + self::CONTACT_AUTO + ]) + ) { + $contactType = Arr::get($params, 'registrant.register.type'); + } else { + $contactType = self::CONTACT_AUTO; + } + + // Create contact instance and get the ID as a string + $registrant = EppHelper::createContact( + $connection, + Arr::get($params, 'registrant.register.email'), + Arr::get($params, 'registrant.register.phone'), + Arr::get($params, 'registrant.register.name', Arr::get($params, 'registrant.register.organisation')), + Arr::get($params, 'registrant.register.organisation', Arr::get($params, 'registrant.register.name')), + Arr::get($params, 'registrant.register.address1'), + Arr::get($params, 'registrant.register.postcode'), + Arr::get($params, 'registrant.register.city'), + Arr::get($params, 'registrant.register.country_code'), + $contactType + ); + + // Check for errors + if (isset($registrant['error'])) { + throw $this->errorResult($registrant['error'], $params, $registrant); + } + + $registrantId = $registrant['contact_id']; + }*/ + + try { + // Request Domain Transfer + EppHelper::transferRequest( + $connection, + $domain, + [], + null, + null, + null, + null, + $eppCode, + $params->renew_years + ); + } catch (eppException $e) { + $errorMessage = $e->getReason() ?: $e->getMessage(); + + if (Str::startsWith($errorMessage, '531 Authorization failed')) { + $errorMessage = sprintf('Incorrect EPP/Auth Code for domain %s', $domain); + } + + if (Str::startsWith($errorMessage, '504 Missing required attribute') && empty($eppCode)) { + $errorMessage = sprintf('EPP/Auth Code required to initiate transfer of %s', $domain); + } + + throw $this->errorResult($errorMessage, $params, [], $e); + } + + try { + // if domain is active, return success + return $this->_getInfo($domain, 'Domain exists in registrar account'); + } catch (eppException $e) { + // domain transfer in progress + throw $this->errorResult(sprintf('Transfer of %s initiated and now in progress', $domain), [ + 'domain' => $domain + ]); + } + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Renew a domain + * + * @param RenewParams $params + * @return ResultData + */ + public function renew(RenewParams $params): DomainResult + { + // Get the domain name + $tld = Arr::get($params, 'tld'); + + $domain = Utils::getDomain(Arr::get($params, 'sld'), $tld); + $period = Arr::get($params, 'renew_years'); + + try { + // Establish the connection + $connection = $this->connect(); + + if (!Utils::tldSupportsExplicitRenewal($tld)) { + // Call PayDomainRenewal to mark domain as paid - renewal will occur implicitly at end of term + $this->api()->markDomainRenewalAsPaid($domain); + + return $this->_getInfo($domain, sprintf('Renewal for %s domain scheduled for end of period', $domain)); + } + + EppHelper::renewDomain($connection, $domain, $period); + + return $this->_getInfo($domain, sprintf('Renewal for %s domain was successful!', $domain)); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Returns full domain information + * + * @param DomainInfoParams $params + * @return ResultData + */ + public function getInfo(DomainInfoParams $params): DomainResult + { + // Get the domain name + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + try { + return $this->_getInfo($domain); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * @throws eppException If call to get domain info fails + */ + public function _getInfo(string $domain, $msg = 'Domain data obtained'): DomainResult + { + // Get Domain Info + $connection = $this->connect(); + $domainInfo = EppHelper::getDomainInfo($connection, $domain); + + $lockedStatuses = [ + 'clientTransferProhibited', + 'clientUpdateProhibited', + 'clientDeleteProhibited', + ]; + $domainInfo['locked'] = boolval(array_intersect($lockedStatuses, $domainInfo['statuses'])); + + if (true || !Utils::tldSupportsExplicitRenewal(Utils::getTld($domain))) { + // For non-explicitly renewable domains, return the "paid until" date, which is effectively the real expiry + $domainInfo['expires_at'] = $this->api()->statusDomain($domain)['PAIDUNTILDATE'][0] + ?? $domainInfo['expires_at']; + } + + if (isset($domainInfo['ns'])) { + foreach ($domainInfo['ns'] as $key => $ns) { + if (isset($ns['ip']) && is_array($ns['ip'])) { + foreach ($ns['ip'] as $k => $v) { + $domainInfo['ns'][$key]['ip'] = $k; + } + } + } + } + + return DomainResult::create($domainInfo, false) + ->setMessage($msg); + } + + /** + * Update Name Servers for a given domain + * + * @param UpdateNameserversParams $params + * @return ResultData + */ + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + // Get Domain Name and NameServers + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $nameServers = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $nameServers[] = Arr::get($params, 'ns' . $i); + } + } + + try { + $newNameservers = $this->api()->setNameservers($domain, $nameServers); + + $returnNameservers = []; + foreach ($newNameservers as $i => $ns) { + $returnNameservers['ns' . ($i + 1)] = $ns; + } + + return NameserversResult::create($returnNameservers) + ->setMessage(sprintf('Name servers for %s domain were updated!', $domain)); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Returns EPP Code for a given domain + * + * @param EppParams $params + * @return ResultData + */ + public function getEppCode(EppParams $params): EppCodeResult + { + // Get the domain name + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + try { + // Establish the connection + $connection = $this->connect(); + + // Get Domain Info + $eppCode = EppHelper::getDomainEppCode($connection, $domain); + + return EppCodeResult::create([ + 'epp_code' => $eppCode, + ])->setMessage('EPP/Auth code obtained'); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Update IPS Tag for a given domain + * + * @param IpsTagParams $params + * @return ResultData + */ + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported', $params); + } + + /** + * Update Registrant Contact for a given domain + * + * @param UpdateDomainContactParams $params + * @return ResultData + */ + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + // Dont use this approach for now - for registrant name/org changes it throws an error requiring a trade + // return to it when we next have an example of a domain with 531 Authorization Error for registrant updates: + // $api = HexonetHelper::establishConnection($this->configuration->toArray()); + // return HexonetHelper::updateRegistrant($api, Utils::getDomain($params->sld, $params->tld), $params->contact) + // ->setMessage('Registrant contact details updated'); + + return $this->updateCreateContact($params, eppContactHandle::CONTACT_TYPE_REGISTRANT) + ->setMessage('Registrant contact details updated'); + } + + /** + * A generic function to handle all contact create/update actions + * + * @param UpdateDomainContactParams $params + * @param string $contactType One of: reg, billing, admin, tech + * + * @throws ProvisionFunctionError + * + * @return ContactResult + */ + protected function updateCreateContact(UpdateDomainContactParams $params, string $contactType): ContactResult + { + // Get the domain name + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + try { + // Establish the connection + $connection = $this->connect(); + + $contactId = EppHelper::getDomainContactId($connection, $domain, $contactType); + + if (!$contactId || $contactId === 'USER') { + // no contact currently set, or is set to immutable reseller contact; create a new one + return $this->createContact($domain, $contactType, $params->contact); + } + + return $this->updateContact($contactId, $params->contact); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Unlocks/Locks a domain for update, delete + transfer. + * + * Toggles ClientProhibitedTransfer, clientUpdateProhibited + clientDeleteProhibited statuses of a domain. + */ + public function setLock(LockParams $params): DomainResult + { + // Get the domain name + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $lock = Arr::get($params, 'lock'); + + try { + $domainInfo = $this->_getInfo($domain); + + $lockedStatuses = [ + 'clientTransferProhibited', + 'clientUpdateProhibited', + 'clientDeleteProhibited', + ]; + + $params = [ + 'domain' => $domain, + ]; + + if ($lock) { + // add statuses + if (!$addStatuses = array_diff($lockedStatuses, $domainInfo->statuses)) { + return $domainInfo->setMessage('Domain already locked'); + } + + foreach (array_values($addStatuses) as $i => $status) { + $params['addstatus' . $i] = $status; + } + + $newStatuses = array_merge($domainInfo->statuses, $addStatuses); + } else { + // remove statuses + if (!$removeStatuses = array_intersect($lockedStatuses, $domainInfo->statuses)) { + return $domainInfo->setMessage('Domain already unlocked'); + } + + foreach (array_values($removeStatuses) as $i => $status) { + $params['delstatus' . $i] = $status; + } + + $newStatuses = array_diff($domainInfo->statuses, $removeStatuses); + } + + $setLock = $this->api()->runCommand('ModifyDomain', $params); + + $domainInfo = array_merge($domainInfo->all(), ['locked' => $lock, 'statuses' => $newStatuses]); + return DomainResult::create($domainInfo) + ->setMessage(sprintf("Lock %s!", $lock ? 'enabled' : 'disabled')) + ->setDebug($setLock->getHash()); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + /** + * Changes the renewal mode to autorenew or autoexpire + * + * @param AutoRenewParams $params + * @return ResultData + */ + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + // Get the domain name + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $autoRenew = !!$params->auto_renew; + + try { + // Unlock domain for transfer + $setRenewalMode = $this->api()->setRenewalMode($domain, $autoRenew); + + // Process Response + if (isset($setRenewalMode['error'])) { + throw $this->errorResult($setRenewalMode['error'], $params, $setRenewalMode); + } + + return $this->_getInfo($domain, 'Auto-renew mode updated'); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + protected function createContact(string $domain, string $contactType, ContactParams $params): ContactResult + { + // Establish the connection + $connection = $this->connect(); + + // Create contact + $contact = EppHelper::createContact( + $connection, + $params->email, + $params->phone, + $params->name, + $params->organisation, + $params->address1, + $params->postcode, + $params->city, + $params->state, + $params->country_code, + $params->type, + $params->password + ); + + // Set contact on domain + EppHelper::setDomainContact($connection, $domain, $contactType, $contact->contact_id); + + return ContactResult::create($contact); + } + + /** + * Update Contact Details + * + * @param string $contactId + * @param ContactParams $params + * @return ContactResult + */ + protected function updateContact(string $contactId, ContactParams $params): ContactResult + { + try { + // Establish the connection + $connection = $this->connect(); + + // Get Contact Data + try { + $contactInfo = EppHelper::getContactInfo($connection, $contactId); + } catch (eppException $e) { + throw $this->errorResult('Invalid registrant ID provided!', compact('contactId', 'params'), [], $e); + } + + + // Set Parameters for the update query + $eppContactType = $params->type; + + if (!in_array($eppContactType, [self::CONTACT_LOC, self::CONTACT_INT, self::CONTACT_AUTO])) { + $eppContactType = self::CONTACT_AUTO; + } + + if ($params->has('name')) { + if ($contactInfo->name == $contactInfo->organisation && !$params->has('organisation')) { + $name = $params->name; + $organisation = $params->name; + } else { + $name = $params->name; + $organisation = $params->get('organisation', $contactInfo->organisation); + } + } elseif ($params->has('organisation')) { + if ($contactInfo->name == $contactInfo->organisation) { + $name = $params->organisation; + $organisation = $params->organisation; + } else { + $name = $params->get('name', $contactInfo->name); + $organisation = $params->organisation; + } + } else { + $name = $contactInfo->name; + $organisation = $contactInfo->organisation; + } + + $contactUpdate = EppHelper::updateDomainContact( + $connection, + $contactId, + $params->get('email', $contactInfo->email), + $params->get('phone', $contactInfo->phone), + $name, + $organisation, + $params->get('address1', $contactInfo->address1), + $params->get('postcode', $contactInfo->postcode), + $params->get('city', $contactInfo->city), + $params->get('state', $contactInfo->state), + $params->get('country_code', $contactInfo->country_code), + $eppContactType + ); + + return ContactResult::create($contactUpdate); + } catch (eppException $e) { + return $this->_eppExceptionHandler($e, $params); + } + } + + protected function _eppExceptionHandler(eppException $exception, $data = [], $debug = []): void + { + if ($exception->getCode() == 2001) { + // command syntax error - just rethrow this cause something is broken + throw $exception; + } + + $errorMessage = $exception->getReason() ?: $exception->getMessage(); + + throw $this->errorResult($errorMessage, $data, $debug, $exception); + } + + + /** + * Ensures the provider instance has a logged in EppConnection and returns it. + * + * @return \Upmind\ProvisionProviders\DomainNames\Hexonet\EppExtension\EppConnection + */ + protected function connect(): EppConnection + { + try { + if (!isset($this->connection) || !$this->connection->isConnected() || !$this->connection->isLoggedin()) { + $this->connection = EppHelper::establishConnection($this->configuration, $this->getLogger()); + } + + return $this->connection; + } catch (eppException $e) { + switch ($e->getCode()) { + case 2001: + $errorMessage = 'Authentication error; check credentials'; + break; + case 2200: + $errorMessage = 'Authentication error; check credentials and whitelisted IPs'; + break; + default: + $errorMessage = 'Unexpected provider connection error'; + } + + throw $this->errorResult(sprintf('%s %s', $e->getCode(), $errorMessage), [], [], $e); + } + } + + /** + * Logout/disconnect any current EPP connection. + */ + protected function disconnect(): void + { + if (isset($this->connection) && $this->connection->isLoggedin()) { + EppHelper::terminateConnection($this->connection); + } + } + + protected function api(): HexonetApi + { + return $this->hexonetApi ??= new HexonetApi($this->configuration, $this->getLogger()); + } +} diff --git a/src/LaravelServiceProvider.php b/src/LaravelServiceProvider.php new file mode 100644 index 0000000..ae7ec02 --- /dev/null +++ b/src/LaravelServiceProvider.php @@ -0,0 +1,49 @@ +bindCategory('domain-names', DomainNames::class); + + $this->bindProvider('domain-names', 'nominet', Nominet::class); + $this->bindProvider('domain-names', 'hexonet', Hexonet::class); + $this->bindProvider('domain-names', 'enom', Enom::class); + $this->bindProvider('domain-names', 'opensrs', OpenSRS::class); + $this->bindProvider('domain-names', 'connect-reseller', ConnectReseller::class); + $this->bindProvider('domain-names', 'logic-boxes', LogicBoxes::class); + $this->bindProvider('domain-names', 'resellerclub', ResellerClub::class); + $this->bindProvider('domain-names', 'netearthone', NetEarthOne::class); + $this->bindProvider('domain-names', 'namesilo', NameSilo::class); + $this->bindProvider('domain-names', 'openprovider', OpenProvider::class); + $this->bindProvider('domain-names', 'resell-biz', ResellBiz::class); + $this->bindProvider('domain-names', 'cocca', CoccaEpp::class); + $this->bindProvider('domain-names', 'nira', Nira::class); + $this->bindProvider('domain-names', 'ricta', Ricta::class); + $this->bindProvider('domain-names', 'ug-registry', UGRegistry::class); + $this->bindProvider('domain-names', 'domain-name-api', DomainNameApi::class); + } +} diff --git a/src/LogicBoxes/Data/Configuration.php b/src/LogicBoxes/Data/Configuration.php new file mode 100644 index 0000000..e330a0f --- /dev/null +++ b/src/LogicBoxes/Data/Configuration.php @@ -0,0 +1,27 @@ + ['required', 'string'], + 'api_key' => ['required', 'string'], + 'sandbox' => ['boolean'], + ]); + } +} diff --git a/src/LogicBoxes/Provider.php b/src/LogicBoxes/Provider.php new file mode 100644 index 0000000..0df6657 --- /dev/null +++ b/src/LogicBoxes/Provider.php @@ -0,0 +1,1103 @@ +configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('LogicBoxes') + ->setDescription( + 'LogicBoxes offers 800+ gTLDs, ccTLDs and new domains at a highly competitive price point' + ); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + $sendedDomains = Arr::get($params, 'domains'); + + $tlds = []; + $domains = []; + foreach ($sendedDomains as $domain) { + $domains[] = Arr::get($domain, 'sld'); + $tlds[] = Arr::get($domain, 'tld'); + } + $checkedDomains = $this->_callApi([ + 'domain-name' => $domains, + 'tlds' => $tlds, + ], 'domains/available.json', 'GET'); + + $domainsIt = count($domains); + $responseDomains = []; + while (--$domainsIt >= 0) { + $domainName = Utils::getDomain(Arr::get($sendedDomains[$domainsIt], 'sld'), Arr::get($sendedDomains[$domainsIt], 'tld')); + $responseDomains[] = array_merge($sendedDomains[$domainsIt], $checkedDomains[$domainName]); + } + + return $this->okResult('Domains checked.', $responseDomains); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $data = [ + 'domain-name' => $domain, + 'years' => Arr::get($params, 'renew_years'), + 'invoice-option' => 'NoInvoice', + // 'purchase-privacy' => true, + // 'protect-privacy' => true, + 'auto-renew' => false, + ]; + + $contacts = $params->toArray(); + $data['customer-id'] = $this->_getCustomerId($contacts, 'registrant'); + $data['reg-contact-id'] = $this->_handelContact($contacts, 'registrant', $data['customer-id'], $params->tld); + $data['admin-contact-id'] = $this->_handelContact($contacts, 'admin', $data['customer-id'], $params->tld); + $data['tech-contact-id'] = $this->_handelContact($contacts, 'tech', $data['customer-id'], $params->tld); + $data['billing-contact-id'] = $this->_handelContact($contacts, 'billing', $data['customer-id'], $params->tld); + + // Determine which name servers to use + $ownNameServers = null; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $ownNameServers[] = Arr::get($params, 'nameservers.ns' . $i . '.host'); + } + } + + // Use the default name servers in case we didn't provide our own + if (!is_null($ownNameServers)) { + $data['ns'] = $ownNameServers; + } else { + $data['ns'] = $this->_getCustomerNameServers($data['customer-id']); // Must start coming from the customer! + } + + $this->_callApi($data, 'domains/register.json'); + $result = $this->_getDomain($domain, 'Domain registered - ' . $domain); + + return $result; + } + + protected function _handelContact(array $params, string $type, string $customerId, string $tld) + { + if (!$this->tldHasContactType($tld, $type)) { + return -1; + } + + if (Arr::has($params, $type . '.id')) { + $contactID = Arr::get($params, $type . '.id'); + } else { + $contactID = $this->_createContact( + Arr::get($params, $type . '.register.email'), + Arr::get($params, $type . '.register.phone'), + Arr::get($params, $type . '.register.name', Arr::get($params, $type . '.register.organisation')), + Arr::get($params, $type . '.register.organisation', Arr::get($params, $type . '.register.name')), + Arr::get($params, $type . '.register.address1'), + Arr::get($params, $type . '.register.postcode'), + Arr::get($params, $type . '.register.city'), + Arr::get($params, $type . '.register.country_code'), + $customerId, + $type, + $tld + ); + } + + return $contactID; + } + + /** + * @link https://manage.logicboxes.com/kb/answer/752 + */ + protected function tldHasContactType(string $tld, string $contactType): bool + { + $tld = Utils::getRootTld($tld); + + if ($contactType === 'registrant') { + return true; + } + + if ($contactType === 'admin') { + return !in_array($tld, ['eu', 'nz', 'ru', 'uk']); + } + + if ($contactType === 'tech') { + return !in_array($tld, ['eu', 'fr', 'nz', 'ru', 'uk']); + } + + if ($contactType === 'billing') { + return !in_array($tld, ['at', 'berlin', 'ca', 'eu', 'fr', 'nl', 'nz', 'ru', 'uk', 'london']); + } + + // unknown contact type + return false; + } + + protected function _handelContactId(array $params) + { + if (Arr::has($params, 'id')) { + $contactID = Arr::get($params, 'id'); + } else { + $contactID = $this->_addContact( + Arr::get($params, 'customer_id'), + Arr::get($params, 'email'), + Arr::get($params, 'phone'), + Arr::get($params, 'name', Arr::get($params, 'organisation')), + Arr::get($params, 'organisation', Arr::get($params, 'name')), + Arr::get($params, 'address1'), + Arr::get($params, 'postcode'), + Arr::get($params, 'city'), + Arr::get($params, 'country_code') + ); + } + + return $contactID; + } + + protected function _getCustomerNameServers(string $customerId): array + { + $data = [ + 'customer-id' => $customerId + ]; + + return $this->_callApi($data, 'domains/customer-default-ns.json', 'GET'); + } + + protected function _getCustomerId(array $params, string $type): string + { + $customer = $this->_getCustomer(Arr::get($params, $type . '.register.email')); + if (count($customer) >= 1) { + return $customer['customerid']; + } + + return (string) $this->_createCustomer( + Arr::get($params, $type . '.register.email'), + Arr::get($params, $type . '.register.phone'), + Arr::get($params, $type . '.register.name', Arr::get($params, $type . '.register.organisation')), + Arr::get($params, $type . '.register.organisation', Arr::get($params, $type . '.register.name')), + Arr::get($params, $type . '.register.address1'), + Arr::get($params, $type . '.register.postcode'), + Arr::get($params, $type . '.register.city'), + Arr::get($params, $type . '.register.country_code') + ); + } + + public function transfer(TransferParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + try { + // check to see if domain is already active in the account + return $this->_getDomain($domain, 'Domain active in registrar account'); + } catch (ProvisionFunctionError $e) { + if (Str::contains(strtolower($e->getMessage()), 'transfer')) { + // transfer already in progress - stop here + throw $e; + } + + // initiate transfer... + } + + // check if domain is eligible for transfer (not locked etc) + if (false === $this->_callApi(['domain-name' => $domain], 'domains/validate-transfer.json', 'GET')) { + return $this->errorResult('Domain is not currently transferrable'); + } + + // initiate the transfer + $contacts = $params->toArray(); + $customerId = $this->_getCustomerId($contacts, 'admin'); + $response = $this->_callApi([ + 'domain-name' => $domain, + 'auth-code' => Arr::get($params, 'epp_code'), + 'customer-id' => $customerId, + 'reg-contact-id' => $this->_handelContact($contacts, 'admin', $customerId, $params->tld), + 'admin-contact-id' => $this->_handelContact($contacts, 'admin', $customerId, $params->tld), + 'tech-contact-id' => $this->_handelContact($contacts, 'admin', $customerId, $params->tld), + 'billing-contact-id' => $this->_handelContact($contacts, 'admin', $customerId, $params->tld), + 'invoice-option' => 'NoInvoice', + 'auto-renew' => false, + ], 'domains/transfer.json', 'POST'); + + if (isset($response['actionstatus']) && $response['actionstatus'] === 'Failed') { + return $this->errorResult('Transfer initiation failed: ' . $response['actionstatusdesc']); + } + + /** + * In case of an error, a status key with value as ERROR along with an error message will be returned. + * However, if the transfer action is waiting on user input or registry response, the value NoError will be returned. + */ + if ($response == 'NoError') { + return $this->errorResult('Transfer awaiting owner or registry approval'); + } + + try { + // check to see if domain transferred instantly + return $this->_getDomain($domain, 'Domain transferred successfully - ' . $domain); + } catch (ProvisionFunctionError $e) { + return $this->errorResult('Domain transfer initiated', [], [], $e); + } + } + + public function release(IpsTagParams $params): ResultData + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $domainData = $this->_getDomain($domain, 'Domain release - ' . $domain); + + $ips_tag = Arr::get($params, 'ips_tag'); + $tag = strlen($ips_tag) == 2 ? '#' . $ips_tag : $ips_tag; + return $this->okResult('Completed', $this->_callApi([ + 'order-id' => $domainData['id'], + 'new-tag' => $tag, + ], 'domains/uk/release.json', 'POST')); + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $newExpiry = $this->_renewDomain($domain, Arr::get($params, 'renew_years')); + + return $this->_getDomain($domain, 'The expire date is extended.') + ->setExpiresAt($newExpiry); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + return $this->_getDomain($domain); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + $domainData = $this->_getDomain($domain); + + $nameservers = [Arr::get($params, 'ns1')['host']]; + // if (!is_null(Arr::get($params, 'ns1.ip'))) { + // $this->_callApi([ + // 'order-id' => $domainData['id'], + // 'cns' => Arr::get($params, 'ns1.host'), + // 'ip' => Arr::get($params, 'ns1.ip'), + // ], 'domains/add-cns.json'); + // } + $nameservers[] = Arr::get($params, 'ns2')['host']; + if (Arr::get($params, 'ns3')) { + $nameservers[] = Arr::get($params, 'ns3')['host']; + } + if (Arr::get($params, 'ns4')) { + $nameservers[] = Arr::get($params, 'ns4')['host']; + } + + $this->_callApi([ + 'order-id' => $domainData['id'], + 'ns' => $nameservers, + ], 'domains/modify-ns.json'); + + $domainData = $this->_getDomain($domain); + + return NameserversResult::create($domainData->ns) + ->setMessage('Nameservers are changed'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + $eppCode = $this->_getEppCode(Utils::getDomain($params->sld, $params->tld)); + + return EppCodeResult::create([ + 'epp_code' => $eppCode, + ]); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + return $this->release($params); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domainName = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $domainData = $this->_getDomainData($domainName); + + $contactData = $params->contact->toArray(); + $contactData['customer_id'] = $domainData['customerid']; + $contactId = $this->_handelContactId($contactData); + + $this->_callApi([ + 'order-id' => $domainData['entityid'], + 'reg-contact-id' => $contactId, + 'admin-contact-id' => $domainData['admincontact']['contactid'] ?? -1, + 'tech-contact-id' => $domainData['techcontact']['contactid'] ?? -1, + 'billing-contact-id' => $domainData['billingcontact']['contactid'] ?? -1, + ], 'domains/modify-contact.json', 'POST'); + + return new ContactResult($this->_contactInfo($contactId)); + } + + public function setLock(LockParams $params): DomainResult + { + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + if ($domainData['locked'] == $params->lock) { + return $domainData->setMessage(sprintf('Domain already %s', $domainData['locked'] ? 'locked' : 'unlocked')); + } + if ($params->lock == true) { + $this->_callApi([ + 'order-id' => $domainData['id'], + ], 'domains/enable-theft-protection.json'); + } else { + $this->_callApi([ + 'order-id' => $domainData['id'], + ], 'domains/disable-theft-protection.json'); + } + + return $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))) + ->setMessage(sprintf('Domain %s', $params->lock ? 'locked' : 'unlocked')); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('Not supported!', $params); + } + + /** + * Used for profile data changes. + * + * @param UpdateDomainContactParams $params + * @return ResultData + */ + public function updateContact(UpdateDomainContactParams $params): ResultData + { + $contactInfo = $this->_contactInfo(Arr::get($params, 'contact_id')); + + $newContactData = Arr::get($params, 'contact', []); + if (Arr::has($newContactData, 'name')) { + if ($contactInfo['name'] == $contactInfo['organisation'] && !Arr::has($newContactData, 'organisation')) { + $name = Arr::get($newContactData, 'name'); + $organisation = Arr::get($newContactData, 'name'); + } else { + $name = Arr::get($newContactData, 'name'); + $organisation = Arr::get($newContactData, 'organisation', $contactInfo['organisation']); + } + } elseif (Arr::has($newContactData, 'organisation')) { + if ($contactInfo['name'] == $contactInfo['organisation']) { + $name = Arr::get($newContactData, 'organisation'); + $organisation = Arr::get($newContactData, 'organisation'); + } else { + $name = Arr::get($newContactData, 'name', $contactInfo['name']); + $organisation = Arr::get($newContactData, 'organisation'); + } + } else { + $name = $contactInfo['name']; + $organisation = $contactInfo['organisation']; + } + + $this->_updateContact( + Arr::get($params, 'contact_id'), + Arr::get($newContactData, 'email', $contactInfo['email']), + Arr::get($newContactData, 'phone', $contactInfo['phone']), + $name, + $organisation, + Arr::get($newContactData, 'address1', $contactInfo['address1']), + Arr::get($newContactData, 'postcode', $contactInfo['postcode']), + Arr::get($newContactData, 'city', $contactInfo['city']), + Arr::get($newContactData, 'country_code', $contactInfo['country_code']) + ); + + return $this->okResult('Contact data.', $this->_contactInfo(Arr::get($params, 'contact_id'))); + } + + /** + * Creates contact and returns its ID or `null` for error + * + * @param string $email + * @param string|null $telephone + * @param string $name + * @param string $organization + * @param string $address + * @param string $postcode + * @param string $city + * @param string $countryCode + * @return integer + */ + protected function _createContact( + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryCode, + string $customerId, + string $type, + string $tld + ): int { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + $phone = phone($telephone); + $phoneCode = $phone->getPhoneNumberInstance()->getCountryCode(); + $phone = $phone->getPhoneNumberInstance()->getNationalNumber(); + } else { + $phoneCode = ''; + $phone = ''; + } + + $contactHash = sha1(json_encode(compact( + 'email', + 'telephone', + 'name', + 'organization', + 'address', + 'postcode', + 'city', + 'countryCode' + ))); + if (isset($this->contactIds[$contactHash])) { + return $this->contactIds[$contactHash]; + } + + $data = [ + 'email' => $email, + 'name' => $name, + 'company' => $organization, + 'address-line-1' => $address, + 'city' => $city, + 'country' => Utils::normalizeCountryCode($countryCode), + 'zipcode' => $postcode, + 'phone-cc' => $phoneCode, + 'phone' => $phone, + 'type' => $this->getTldContactType($tld, $type), + 'customer-id' => $customerId, + ]; + + // returns the contact id + return $this->contactIds[$contactHash] = $this->_callApi($data, 'contacts/add.json'); + } + + /** + * @link https://manage.logicboxes.com/kb/answer/790 + * @link https://manage.logicboxes.com/kb/answer/752 + */ + protected function getTldContactType(string $tld, string $type): string + { + $tld = Utils::getRootTld($tld); + + if ($tld === 'br') { + return $type === 'registrant' ? 'BrOrgContact' : 'BrContact'; + } + + $map = [ + 'at' => 'AtContact', + 'ca' => 'CaContact', + 'cl' => 'ClContact', + 'cn' => 'CnContact', + 'co' => 'CoContact', + 'co' => 'CoopContact', + 'de' => 'DeContact', + 'es' => 'EsContact', + 'eu' => 'EuContact', + 'fr' => 'FrContact', + 'mx' => 'MxContact', + 'nl' => 'NlContact', + 'ny' => 'NycContact', + 'uk' => 'UkContact', + 'ru' => 'RuContact', + ]; + + return $map[$tld] ?? 'Contact'; + } + + protected function _addContact( + string $customerId, + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryCode + ): int { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + $phone = phone($telephone); + $phoneCode = $phone->getPhoneNumberInstance()->getCountryCode(); + $phone = $phone->getPhoneNumberInstance()->getNationalNumber(); + } else { + $phoneCode = ''; + $phone = ''; + } + + $data = [ + 'email' => $email, + 'name' => $name, + 'company' => $organization, + 'address-line-1' => $address, + 'city' => $city, + 'country' => Utils::normalizeCountryCode($countryCode), + 'zipcode' => $postcode, + 'phone-cc' => $phoneCode, + 'phone' => $phone, + 'type' => 'Contact', + 'customer-id' => $customerId, + ]; + + return $this->_callApi($data, 'contacts/add.json'); // Expected integer - contact_id + } + + /** + * Returns customer data if exist + * + * @param string $email + * @return array + */ + protected function _getCustomer(string $email): array + { + $data = [ + 'username' => $email + ]; + + try { + return $this->_callApi($data, 'customers/details.json', 'GET'); + } catch (Exception $e) { + return []; + } + } + + /** + * @link https://manage.logicboxes.com/kb/servlet/KBServlet/faq489.html#password + */ + protected function _generateRandomPassword(): string + { + $special = '~*!@$#%_+.?:,{}'; + $upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + $characters = 'abcdefghijklmnopqrstuvwxyz'; + $numbers = '0123456789'; + $charactersLength = strlen($characters); + $specialLength = strlen($special); + $upperLength = strlen($upper); + $numbersLength = strlen($numbers); + $randomString = $special[rand(0, $specialLength - 1)]; + $randomString .= $upper[rand(0, $upperLength - 1)]; + for ($i = 0; $i < 3; $i++) { + $randomString .= $characters[rand(0, $charactersLength - 1)]; + $randomString .= $numbers[rand(0, $numbersLength - 1)]; + } + $randomString .= $special[rand(0, $specialLength - 1)]; + $randomString .= $upper[rand(0, $upperLength - 1)]; + return $randomString; + } + + /** + * Creates contact and returns its ID or `null` for error + * + * @param string $email + * @param string $telephone + * @param string $name + * @param string|null $organization + * @param string $address + * @param string $postcode + * @param string $city + * @param string $countryCode + * @return integer + */ + protected function _createCustomer( + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryCode + ): int { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + $phone = phone($telephone); + $phoneCode = $phone->getPhoneNumberInstance()->getCountryCode(); + $phone = $phone->getPhoneNumberInstance()->getNationalNumber(); + } else { + $phoneCode = ''; + $phone = ''; + } + $data = [ + 'username' => $email, + 'passwd' => $this->_generateRandomPassword(), + 'name' => $name, + 'company' => $organization, + 'address-line-1' => $address, + 'city' => $city, + 'state' => '-', + 'country' => Utils::normalizeCountryCode($countryCode), + 'zipcode' => $postcode, + 'phone-cc' => $phoneCode, + 'phone' => $phone, + 'lang-pref' => 'en' + ]; + + return $this->_callApi($data, 'customers/v2/signup.json'); // Expected integer - contact_id + } + + protected function _callApi(array $data, string $path, string $method = 'POST') + { + if ($this->configuration['sandbox']) { + $url = 'https://test.httpapi.com/api/'; + } else { + $url = 'https://httpapi.com/api/'; + } + $url .= $path; + + $client = new Client([ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'multipart/form-data', + ], + 'http_errors' => true, + 'handler' => $this->getGuzzleHandlerStack(boolval($this->configuration->sandbox)), + ]); + + $query = array_merge( + $data, + ['auth-userid' => $this->configuration['reseller_id'], 'api-key' => $this->configuration['api_key']] + ); + $query = preg_replace('/\%5B\d+\%5D/', '', http_build_query($query)); + + try { + switch (strtoupper($method)) { + case 'GET': + case 'DELETE': //fall-through + $response = $client->request($method, $url, [ + 'query' => $query, + ]); + break; + default: + $response = $client->request($method, $url, [ + 'query' => $query, + // 'body' => $query, + ]); + break; + } + + $responseData = $this->getResponseData($response); + + if (isset($responseData['status'])) { + $status = strtolower($responseData['status']); + if ($status === 'error') { + $errorMessage = $this->getResponseErrorMessage($responseData); + + throw $this->errorResult( + sprintf('Provider API %s: %s', $status, $errorMessage), + [], + ['response_data' => $responseData], + ); + } + } + + return $responseData; + } catch (Throwable $e) { + $this->handleException($e); + + throw $e; + } + } + + /** + * Obtain the response body data from the given api response. + * + * @return array|string|int + */ + protected function getResponseData(Response $response) + { + $body = trim($response->getBody()->__toString()); + + return json_decode($body, true); + } + + /** + * Get a friendly error message from the given response data. + * + * @param array $responseData + * + * @return string + */ + protected function getResponseErrorMessage($responseData): string + { + $errorMessage = trim($responseData['message'] ?? $responseData['error'] ?? 'unknown error'); + + // only return the first sentence of the error message + $errorMessage = preg_replace('/\. .+$/', '', $errorMessage); + + // neaten up validation errors + if (preg_match('/^\{\w+=([^}]+)\}$/', $errorMessage)) { + // remove weird-ass curly braces + $errorMessage = trim($errorMessage, '{}'); + + // remove attribute names from error messages + $errorMessage = preg_replace('/\w+=(?=\w+ )/', '', $errorMessage); + + // ucfirst each error message + $errorMessage = collect(explode(', ', $errorMessage)) + ->map(function ($message) { + return ucfirst($message); + }) + ->implode('; '); + } + + // override confusing "not found" error message + if (Str::startsWith($errorMessage, 'Website doesn\'t exist')) { + $errorMessage = 'Domain name not found'; + } + + // override "not registered" error message + if (Str::contains($errorMessage, 'is currently available for Registration')) { + $errorMessage = 'Domain is not registered'; + } + + return $errorMessage; + } + + /** + * @throws ProvisionFunctionError + * @throws Throwable If error is completely unexpected + * + * @return no-return + */ + protected function handleException(Throwable $e): void + { + if ($e instanceof RequestException) { + if ($e->hasResponse()) { + $response = $e->getResponse(); + + // text/plain responses + if (Str::contains($response->getHeaderLine('Content-Type'), 'text/plain')) { + $body = trim($response->getBody()->__toString()); + + // check for error codes + if (preg_match('/error code: (\d+)/i', $body, $matches)) { + switch ($matches[1]) { + case "1020": + throw $this->errorResult( + 'Provider API rejected our request - please review whitelisted IPs', + [], + ['response_body' => $body], + $e + ); + default: + throw $this->errorResult( + sprintf('Unexpected provider API error: %s', $matches[1]), + [], + ['response_body' => $body], + $e + ); + } + } + } + + // application/json responses + $responseData = $this->getResponseData($response); + + $status = strtolower($responseData['status'] ?? 'error'); + $errorMessage = $this->getResponseErrorMessage($responseData); + + throw $this->errorResult( + sprintf('Provider API %s: %s', ucfirst($status), $errorMessage), + [], + ['response_data' => $responseData], + $e + ); + } + } + + // totally unexpected error - re-throw and let provision system handle + throw $e; + } + + protected function _getDomain( + string $domainName, + string $msg = 'Domain data retrieved', + bool $assertActive = true + ): DomainResult { + $domainData = $this->_getDomainData($domainName); + + $ns = []; + foreach (['ns1', 'ns2', 'ns3', 'ns4'] as $nsI) { + if (isset($domainData[$nsI])) { + $ns[$nsI] = [ + 'host' => $domainData[$nsI], + ]; + } + } + + $datetimeCreated = $domainData['creationtime'] ?? null; // On new order might be missing + $datetimeEnd = $domainData['endtime'] ?? null; // On new order might be missing + $info = DomainResult::create([ + 'id' => $domainData['entityid'], + 'domain' => $domainData['domainname'], + 'statuses' => array_merge([$domainData['currentstatus']], $domainData['domainstatus']), + 'locked' => in_array('transferlock', $domainData['orderstatus']) ? true : false, + 'renew' => $domainData['recurring'] == 'false' ? false : true, + 'registrant' => $this->_parseContactInfo($domainData['registrantcontact']), + 'ns' => $ns, + 'created_at' => $this->formatDate($datetimeCreated), + 'updated_at' => $this->formatDate($datetimeCreated) ?? $this->formatDate($datetimeCreated), + 'expires_at' => $this->formatDate($datetimeEnd), + ]) + ->setMessage($msg); + + if ($assertActive && $domainData['currentstatus'] !== 'Active') { + $message = 'Domain is not active'; + + if (isset($domainData['actionstatusdesc'])) { + $message .= ' - ' . $domainData['actionstatusdesc']; + } + + if (isset($domainData['actiontype']) && $domainData['actiontype'] === 'AddTransferDomain') { + // transfer in progress + $message = 'Domain transfer in progress'; + + if ($initiatedTimestamp = $domainData['executioninfoparams']['invoicepaidtime'] ?? null) { + $initiated = CarbonImmutable::parse(intval($initiatedTimestamp)); + } + + if ($initiated && $initiated->addDays(7)->greaterThan(Carbon::now())) { + $message .= ' since ' . $initiated->diffForHumans(); + } elseif ($domainData['actionstatusdesc']) { + $message .= ' - ' . $domainData['actionstatusdesc']; + } + } + + throw $this->errorResult( + $message, + $info->toArray(), + ['response_data' => $domainData] + ); + } + + return $info; + } + + /** + * Get domain details by domain name. + */ + protected function _getDomainData(string $domainName): array + { + return $this->_callApi( + [ + 'domain-name' => $domainName, + 'options' => 'All', + ], + 'domains/details-by-name.json', + 'GET' + ); + } + + protected function _getEppCode(string $domainName): string + { + $domainData = $this->_callApi( + [ + 'domain-name' => $domainName, + 'options' => 'OrderDetails', + ], + 'domains/details-by-name.json', + 'GET' + ); + + return $domainData['domsecret']; + } + + protected function _parseContactInfo(array $contact): array + { + return [ + 'id' => $contact['contactid'], + 'name' => $contact['name'], + 'email' => $contact['emailaddr'], + 'phone' => '+' . $contact['telnocc'] . $contact['telno'], + 'organisation' => $contact['company'], + 'address1' => $contact['address1'], + 'city' => $contact['city'], + 'postcode' => $contact['zip'], + 'country_code' => $contact['country'], + 'status' => $contact['contactstatus'], + ]; + } + + protected function _contactInfo(int $contactID): array + { + $contactData = $this->_callApi( + [ + 'contact-id' => $contactID + ], + 'contacts/details.json', + 'GET' + ); + + return $this->_parseContactInfo($contactData); + } + + protected function _updateContact( + string $contactID, + string $email, + string $telephone, + string $name, + string $organization, + string $address, + string $postcode, + string $city, + string $country + ): array { + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + $phone = phone($telephone); + $phoneCode = $phone->getPhoneNumberInstance()->getCountryCode(); + $phone = $phone->getPhoneNumberInstance()->getNationalNumber(); + } else { + $phoneCode = ''; + $phone = ''; + } + + return $this->_callApi( + [ + 'contact-id' => $contactID, + 'email' => $email, + 'name' => $name, + 'company' => $organization, + 'address-line-1' => $address, + 'city' => $city, + 'country' => Utils::normalizeCountryCode($country), + 'zipcode' => $this->normalizePostCode($postcode, $country), + 'phone-cc' => $phoneCode, + 'phone' => $phone, + ], + 'contacts/modify.json' + ); // Array + } + + protected function formatDate(?string $date): ?string + { + if (!isset($date)) { + return $date; + } + return Carbon::parse((int) $date)->toDateTimeString(); + } + + /** + * Renew domain + * + * @param string $domainName + * + * @return DateTimeInterface New expiry date + */ + protected function _renewDomain(string $domainName, int $renew_years): DateTimeInterface + { + $domain = $this->_getDomain($domainName, 'The expire date is extended.'); + $this->_callApi( + [ + 'order-id' => $domain['id'], + 'years' => $renew_years, + 'exp-date' => Carbon::parse($domain['expires_at'])->unix(), + 'auto-renew' => $domain['renew'], + 'invoice-option' => 'NoInvoice', + ], + 'domains/renew.json' + ); + + return Carbon::parse($domain->expires_at)->addYears($renew_years); + } + + /** + * Normalize a given contact address post code to satisfy nominet + * requirements. If a GB postcode is given, this method will ensure a space + * is inserted in the correct place. + * + * @param string $postCode Postal code e.g., SW152QT + * @param string $countryCode 2-letter iso code e.g., GB + * + * @return string Post code e.g., SW15 2QT + */ + protected function normalizePostCode(?string $postCode, ?string $countryCode = 'GB'): ?string + { + if (!isset($postCode) || !isset($countryCode) || $this->normalizeCountryCode($countryCode) !== 'GB') { + return $postCode; + } + + return preg_replace( + '/^([a-z]{1,2}[0-9][a-z0-9]?) ?([0-9][a-z]{2})$/i', + '${1} ${2}', + $postCode + ); + } + + protected function normalizeCountryCode(string $countryCode): string + { + return Utils::normalizeCountryCode($countryCode); + } +} diff --git a/src/NameSilo/Data/NameSiloConfiguration.php b/src/NameSilo/Data/NameSiloConfiguration.php new file mode 100644 index 0000000..3e19605 --- /dev/null +++ b/src/NameSilo/Data/NameSiloConfiguration.php @@ -0,0 +1,25 @@ + ['required', 'string'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/NameSilo/Provider.php b/src/NameSilo/Provider.php new file mode 100644 index 0000000..9cf7f01 --- /dev/null +++ b/src/NameSilo/Provider.php @@ -0,0 +1,1166 @@ +configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('NameSilo') + ->setDescription('Register, transfer, renew and manage domains with NameSilo'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + $sendedDomains = Arr::get($params, 'domains'); + + $tlds = []; + $domains = ''; + foreach ($sendedDomains as $domain) { + $domains .= Arr::get($domain, 'sld') . '.' . Arr::get($domain, 'tld'); + } + $domains = ltrim($domains, ','); + $checkedDomains = $this->_callApi([ + 'domains' => $domains + ], 'checkRegisterAvailability', 'GET'); + + $responseDomains = []; + + if (isset($checkedDomains->reply->available->domain)) { + $num = count($checkedDomains->reply->available->domain); + for ($i = 0; $i < $num; $i++) { + $parts = Utils::getSldTld((string)$checkedDomains->reply->available->domain[$i]); + + $responseDomains[] = [ + 'sld' => $parts['sld'], + 'tld' => $parts['tld'], + 'domain' => (string)$checkedDomains->reply->available->domain[$i], + 'available' => true, + 'reason' => null + ]; + } + } + + if (isset($checkedDomains->reply->unavailable->domain)) { + $num = count($checkedDomains->reply->unavailable->domain); + for ($i = 0; $i < $num; $i++) { + $parts = Utils::getSldTld((string)$checkedDomains->reply->unavailable->domain[$i]); + + $responseDomains[] = [ + 'sld' => $parts['sld'], + 'tld' => $parts['tld'], + 'domain' => (string)$checkedDomains->reply->unavailable->domain[$i], + 'available' => false, + 'reason' => null + ]; + } + } + + return $this->okResult('Domains checked.', $responseDomains); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Polling not available for this provider'); + + $countRemaining = 0; + $notifications = []; + $since = $params->after_date ? Carbon::parse($params->after_date) : null; + + $timeLimit = 60; //sec + $startTime = time(); + while ($countRemaining <= $params->limit) { + $listDomains = $this->_callApi([], 'listDomains'); + if (!isset($listDomains->reply->domains->domain)) { + break; + } + + $countRemaining += count($listDomains->reply->domains->domain); + for ($i = 0; $i < count($listDomains->reply->domains->domain); $i++) { + $domain = (string)$listDomains->reply->domains->domain[$i]; + + $checkTransferStatus = $this->_callApi([ + 'domain' => $domain + ], 'checkTransferStatus', 'GET'); + + + if ($since !== null && $since->gt(Carbon::parse((string)$checkTransferStatus->reply->date))) { + continue; + } + + if (time() - $startTime >= $timeLimit) { + break 2; + } + + $status = $this->mapType((string)$checkTransferStatus->reply->status); + + if ($status == null) { + continue; + } + + $notifications[] = DomainNotification::create() + ->setId('N/A') + ->setType($status) + ->setMessage((string)$checkTransferStatus->reply->message) + ->setDomains([$domain]) + ->setCreatedAt(Carbon::parse((string)$checkTransferStatus->reply->date)) + ->setExtra(['xml' => $checkTransferStatus->saveXML()]); + } + } + + return new PollResult([ + 'count_remaining' => $countRemaining, + 'notifications' => $notifications, + ]); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $checkedDomains = $this->_callApi([ + 'domains' => $domain + ], 'checkRegisterAvailability', 'GET'); + + if (isset($checkedDomains->reply->unavailable->domain)) { + return $this->errorResult('The domain is unavailable!'); + } + + $data = [ + 'domain' => $domain, + 'years' => Arr::get($params, 'renew_years'), + 'private' => 1, + 'auto_renew' => 0, + ]; + + $contactIds = [ + 'registrant' => $this->_handleContact($params->registrant, $domain), + 'administrative' => $this->_handleContact($params->admin, $domain), + 'technical' => $this->_handleContact($params->tech, $domain), + 'billing' => $this->_handleContact($params->billing, $domain), + ]; + + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $data['ns' . $i] = Arr::get($params, 'nameservers.ns' . $i . '.host'); + } + } + + $this->_callApi($data, 'registerDomain'); + + foreach ($contactIds as $type => $contactId) { + $this->_associateContact($domain, (string)$contactId, $type); + } + + $this->_addRegisteredNameServer($params); + return $this->_getDomain($domain, 'Domain registered - ' . $domain); + } + + /** + * Get a contact id for the given contact params. + * + * @param RegisterContactParams $params + * + * @return string Contact id + */ + protected function _handleContact(RegisterContactParams $params): string + { + if ($params->id) { + return $params->id; + } + + return $this->_createContact( + $params->register->email, + $params->register->phone, + $params->register->name ?? $params->register->organisation, + $params->register->organisation, + $params->register->address1, + $params->register->postcode, + $params->register->city, + $params->register->country_code, + $params->register->state ?? '-' + ); + } + + public function transfer(TransferParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $transferStatus = $this->getTransferStatus($domain); + + try { + $info = $this->_getDomain($domain, 'Domain active in registrar account', false); + + if (in_array(['Active'], $info->statuses)) { + return $info; + } + + if ($transferStatus && $this->transferStatusInProgress($transferStatus)) { + throw $this->errorResult( + sprintf('Domain transfer in progress since %s', $info->created_at), + array_merge($info, ['transfer_status' => $transferStatus]) + ); + } + + // transfer failed - proceed to initiate new transfer + } catch (ProvisionFunctionError $e) { + if (Str::startsWith($e->getMessage(), 'Domain transfer in progress')) { + throw $e; + } + + if ($transferStatus && $this->transferStatusInProgress($transferStatus)) { + throw $this->errorResult('Domain transfer in progress', ['transfer_status' => $transferStatus]); + } + + // domain does not exist - proceed to initiate transfer + } + + // check if domain is eligible for transfer (not locked etc) + $checkTransferAvailability = $this->_callApi(['domains' => $domain], 'checkTransferAvailability'); + + if (!isset($checkTransferAvailability->reply->available->domain)) { + return $this->errorResult('Domain not eligible for transfer', [ + 'availability_response' => $checkTransferAvailability, + ]); + } + + $this->_callApi([ + 'domain' => $domain, + 'auth' => Arr::get($params, 'epp_code'), + 'contact_id' => $this->_handleContact($params->admin), + 'auto_renew' => 0, + ], 'transferDomain'); + + return $this->errorResult('Domain transfer initiated'); + } + + /** + * Returns the transfer status of the given domain name, if any. + */ + protected function getTransferStatus(string $domain): ?string + { + try { + $result = $this->_callApi(['domain' => $domain], 'checkTransferStatus'); + return isset($result->reply->status) ? ((string)$result->reply->status ?: null) : null; + } catch (ProvisionFunctionError $e) { + return null; + } + } + + public function release(IpsTagParams $params): ResultData + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $domainData = $this->_getDomain($domain, 'Domain release - ' . $domain); + + $ips_tag = Arr::get($params, 'ips_tag'); + $tag = strlen($ips_tag) == 2 ? '#' . $ips_tag : $ips_tag; + return $this->okResult('Completed', $this->_callApi([ + 'order-id' => $domainData['id'], + 'new-tag' => $tag, + ], 'domains/uk/release.json', 'POST')); + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $this->_renewDomain($domain, Arr::get($params, 'renew_years')); + + return $this->_getDomain($domain, 'The expire date is extended.'); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + return $this->_getDomain($domain); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $this->_updateRegisteredNameServer($params); + + $domain = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + + return NameserversResult::create($domain->ns) + ->setMessage('Nameservers updated'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + $eppCode = $this->_getEppCode(Utils::getDomain($params->sld, $params->tld)); + + return EppCodeResult::create([ + 'epp_code' => $eppCode, + ]); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + return $this->errorResult('Not supported!', $params); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domainName = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $domainData = $this->_callApi( + [ + 'domain' => $domainName, + ], + 'getDomainInfo' + ); + + $contactIds = $domainData->reply->contact_ids; + $registrantId = (string)$contactIds->registrant; + + if (in_array($registrantId, [$contactIds->administrative, $contactIds->technical, $contactIds->billing])) { + // contact ID is shared with other contacts - create new contact + $registrantId = $this->_createContact( + $params->contact->email, + $params->contact->phone, + $params->contact->name ?: $params->contact->organisation, + $params->contact->organisation ?: $params->contact->name, + $params->contact->address1, + $params->contact->postcode, + $params->contact->city, + $params->contact->country_code, + $params->contact->state + ); + + $this->_associateContact($domainName, $registrantId, 'registrant'); + } else { + $this->_updateContact( + $registrantId, + $params->contact->email, + $params->contact->phone, + $params->contact->name ?: $params->contact->organisation, + $params->contact->organisation ?: $params->contact->name, + $params->contact->address1, + $params->contact->postcode, + $params->contact->city, + $params->contact->country_code, + $params->contact->state + ); + } + + return $this->_contactInfo($registrantId); + } + + public function setLock(LockParams $params): DomainResult + { + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + if ($domainData['locked'] == $params->lock) { + return $domainData->setMessage(sprintf('Domain already %s', $domainData['locked'] ? 'locked' : 'unlocked')); + } + if ($params->lock == true) { + $path = 'domainLock'; + } else { + $path = 'domainUnlock'; + } + + $this->_callApi([ + 'domain' => Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')), + ], $path); + + return $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))) + ->setMessage(sprintf('Domain %s', $params->lock ? 'locked' : 'unlocked')); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + $domainName = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + if ($domainData['renew'] == $params->auto_renew) { + return $this->errorResult(sprintf('Domain already is set to %s', $domainData['renew'] ? 'auto renew' : 'handle renew'), $params); + } + if ($params->auto_renew == true) { + $path = 'addAutoRenewal'; + } else { + $path = 'removeAutoRenewal'; + } + + $this->_callApi( + [ + 'domain' => $domainName, + ], + $path + ); + + return $this->_getDomain($domainName) + ->setMessage('Auto-renew mode updated'); + } + + /** + * Returns customer data if exist + * + * @param string $email + * @return array + */ + protected function _getContact(string $email): array + { + try { + $listedContacts = $this->_callApi([], 'contactList'); + + if (isset($listedContacts->reply->contact)) { + $num = count($listedContacts->reply->contact); + for ($i = 0; $i < $num; $i++) { + if ((string)$listedContacts->reply->contact[$i]->email == $email) { + return [ + 'contact_id' => (string)$listedContacts->reply->contact[$i]->contact_id, + 'name' => (string)$listedContacts->reply->contact[$i]->first_name . ' ' . (string)$listedContacts->reply->contact->last_name, + 'email' => (string)$listedContacts->reply->contact[$i]->email, + 'phone' => (string)$listedContacts->reply->contact[$i]->phone, + 'company' => (string)$listedContacts->reply->contact[$i]->company, + 'address1' => (string)$listedContacts->reply->contact[$i]->address, + 'city' => (string)$listedContacts->reply->contact->city, + 'postcode' => (string)$listedContacts->reply->contact[$i]->zip, + 'country_code' => (string)$listedContacts->reply->contact[$i]->country, + 'state' => Utils::stateNameToCode((string)$listedContacts->reply->contact[$i]->country, (string)$listedContacts->reply->contact[$i]->sate), + ]; + } + } + } + return []; + } catch (Exception $e) { + return []; + } + } + + protected function _callApiPromise(array $data, string $path): PromiseInterface + { + $query = array_merge( + $data, + [ + 'version' => 1, + 'type' => 'xml', + 'key' => $this->configuration['api_key'], + ] + ); + + return $this->http() + ->requestAsync('GET', $path . '?' . http_build_query($query), [ + 'headers' => [ + 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.104 Safari/537.36', + 'accept' => 'text/xml,application/xhtml+xml,application/xml;q=0.9' + ] + ]) + ->then(function (ResponseInterface $response) { + return $this->handleResponse($response); + }) + ->otherwise(function (Throwable $e) { + throw $this->handleException($e); + }); + } + + protected function _callApi(array $data, string $path): SimpleXMLElement + { + return $this->_callApiPromise($data, $path)->wait(); + } + + /** + * @link https://www.namesilo.com/api-reference + * + * @throws ProvisionFunctionError If we encounter an error response + */ + protected function handleResponse(ResponseInterface $response): SimpleXMLElement + { + $xmlString = trim($response->getBody()->getContents()); + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($xmlString); + + if (empty(trim($xmlString)) || $xmlError = $this->display_xml_error(libxml_get_errors(), $xmlString)) { + throw $this->errorResult( + 'Invalid Provider Response', + ['xml_error' => $xmlError ?? 'Empty Response'], + ['xml' => $xmlString] + ); + } + + $code = isset($xml->reply->code) ? intval($xml->reply->code) : null; + + if ($code !== 300) { + $message = $this->getResponseErrorMessage($code, $xml); + $description = $this->getResponseErrorDescription($code); + + throw $this->errorResult( + $message, + ['error_code' => $code, 'error_description' => $description], + ['xml' => $xmlString] + ); + } + + return $xml; + } + + /** + * Get a customer-friendly error message from the given response data. + * + * @param int|null $code Error code + * @param SimpleXMLElement $xml Parsed XML response + * + * @return string Customer-friendly error message + */ + protected function getResponseErrorMessage(?int $code, SimpleXMLElement $xml): string + { + $message = strval($xml->reply->detail ?? 'Unknown Error'); + + // override specific messages + switch ($code) { + case 113: + $message = sprintf('IP address %s has not been granted access', $xml->request->ip ?? 'UNKNOWN'); + break; + case 119: + $message = 'Insufficient funds'; + break; + } + + return sprintf('Provider API Error: %s', str_ireplace('namesilo', 'provider', $message)); + } + + /** + * Get the error description of the given API response error code. + * + * @link https://www.namesilo.com/api-reference Response Codes + * + * @param int|null $code Error code + * + * @return string + */ + protected function getResponseErrorDescription(?int $code): string + { + switch ($code) { + case 101: + return 'HTTPS not used'; + case 102: + return 'No version specified'; + case 103: + return 'Invalid API version'; + case 104: + return 'No type specified'; + case 105: + return 'Invalid API type'; + case 106: + return 'No operation specified'; + case 107: + return 'Invalid API operation'; + case 108: + return 'Missing parameters for the specified operation'; + case 109: + return 'No API key specified'; + case 110: + return 'Invalid API key'; + case 111: + return 'Invalid User'; + case 112: + return 'API not available to Sub-Accounts'; + case 113: + return 'This API account cannot be accessed from your IP'; + case 114: + return 'Invalid Domain Syntax'; + case 115: + return 'Central Registry Not Responding - try again later'; + case 116: + return 'Invalid sandbox account'; + case 117: + return 'The provided credit card profile either does not exist, or is not associated with your account'; + case 118: + return 'The provided credit card profile has not been verified'; + case 119: + return 'Insufficient account funds for requested transaction'; + case 120: + return 'API key must be passed as a GET'; + case 200: + return 'Domain is not active, or does not belong to this user'; + case 201: + return 'Internal system error'; + case 210: + return 'General error (details provided in response)'; + case 250: + return 'Domain is already set to AutoRenew - No update made.'; + case 251: + return 'Domain is already set not to AutoRenew - No update made.'; + case 252: + return 'Domain is already Locked - No update made.'; + case 253: + return 'Domain is already Unlocked - No update made.'; + case 254: + return 'NameServer update cannot be made. (details provided in response)'; + case 255: + return 'Domain is already Private - No update made.'; + case 256: + return 'Domain is already Not Private - No update made.'; + case 261: + return 'Domain processing error (details provided in response)'; + case 262: + return 'This domain is already active within our system and therefore cannot be processed.'; + case 263: + return 'Invalid number of years, or no years provided.'; + case 264: + return 'Domain cannot be renewed for specified number of years (details provided in response)'; + case 265: + return 'Domain cannot be transferred at this time (details provided in response)'; + case 266: + return 'No domain transfer exists for this user for this domain'; + case 267: + return 'Invalid domain name, or we do not support the provided extension/TLD.'; + case 280: + return 'DNS modification error'; + case 300: + return 'Successful API operation'; + case 301: + return 'Successful registration, but not all provided hosts were valid resulting in our nameservers being used'; + case 302: + return 'Successful order, but there was an error with the contact information provided so your account default contact profile was used (you can configure your account to reject orders with invalid contact information via the Reseller Manager page in your account.)'; + break; + case 400: + return 'Existing API request is still processing - request will need to be re-submitted'; + default: + return 'Unknown error code'; + } + } + + /** + * @link https://www.php.net/manual/en/function.libxml-get-errors.php#refsect1-function.libxml-get-errors-examples + */ + protected function display_xml_error($error, $xml) + { + if (is_array($error)) { + $return = ''; + foreach ($error as $e) { + $return .= $this->display_xml_error($e, $xml); + } + return $return; + } + + $return = $xml[$error->line - 1] . "\n"; + $return .= str_repeat('-', $error->column) . "^\n"; + + switch ($error->level) { + case LIBXML_ERR_WARNING: + $return .= "Warning $error->code: "; + break; + case LIBXML_ERR_ERROR: + $return .= "Error $error->code: "; + break; + case LIBXML_ERR_FATAL: + $return .= "Fatal Error $error->code: "; + break; + } + + $return .= trim($error->message) . + "\n Line: $error->line" . + "\n Column: $error->column"; + + if ($error->file) { + $return .= "\n File: $error->file"; + } + + return "$return\n---\n"; + } + + + /** + * @param string $email + * @param string $telephone + * @param string $name + * @param string|null $organization + * @param string $address + * @param string $postcode + * @param string $city + * @param string $countryCode + * @return string + * @throws Throwable + */ + protected function _createContact( + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryCode, + ?string $state + ): string { + $lastName = ''; + $nameParts = explode(' ', $name); + if (isset($nameParts[1])) { + $lastName = $nameParts[1]; + } + $firstName = $nameParts[0]; + if (!$lastName) { + $lastName = $firstName; + } + + $data = [ + 'em' => $email, + 'fn' => $firstName, + 'ln' => $lastName, + 'cp' => $organization, + 'ad' => $address, + 'cy' => $city, + 'st' => Utils::stateNameToCode($countryCode, $state) ?: '-', + 'ct' => Utils::normalizeCountryCode($countryCode), + 'zp' => $postcode, + 'ph' => Utils::internationalPhoneToEpp($telephone), + ]; + + $contact = $this->_callApi($data, 'contactAdd'); + + return (string)$contact->reply->contact_id; + } + + protected function _updateContact( + string $contactId, + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryCode, + ?string $state = null + ): void { + unset($this->contactIds[$contactId]); + + $lastName = ''; + $nameParts = explode(' ', $name); + if (isset($nameParts[1])) { + $lastName = $nameParts[1]; + } + $firstName = $nameParts[0]; + if (!$lastName) { + $lastName = $firstName; + } + + $data = [ + 'contact_id' => $contactId, + 'em' => $email, + 'fn' => $firstName, + 'ln' => $lastName, + 'cp' => $organization, + 'ad' => $address, + 'cy' => $city, + 'st' => Utils::stateNameToCode($countryCode, $state) ?: '-', + 'ct' => Utils::normalizeCountryCode($countryCode), + 'zp' => $postcode, + 'ph' => Utils::internationalPhoneToEpp($telephone), + ]; + + $this->_callApi($data, 'contactUpdate'); + } + + /** + * @throws ProvisionFunctionError + * @throws Throwable If error is completely unexpected + * + * @return no-return + */ + protected function handleException(Throwable $e): void + { + if ($e instanceof RequestException) { + if ($e->hasResponse()) { + $response = $e->getResponse(); + + $httpCode = $response->getStatusCode(); + $reason = $response->getReasonPhrase(); + $responseBody = $response->getBody()->__toString(); + } + + throw $this->errorResult( + 'Provider API request failed', + [ + 'http_code' => $httpCode ?? null, + 'reason' => $reason ?? null, + ], + [ + 'response_body' => $responseBody ?? null + ], + $e + ); + } + + // totally unexpected error - re-throw and let provision system handle + throw $e; + } + + protected function _getDomain( + string $domainName, + string $msg = 'Domain data retrieved', + bool $assertActive = true + ): DomainResult { + $domainData = $this->_callApi( + [ + 'domain' => $domainName, + ], + 'getDomainInfo' + ); + + $ns = []; + for ($i = 0; $i < count($domainData->reply->nameservers->nameserver); $i++) { + if (isset($domainData->reply->nameservers->nameserver[$i])) { + $ns['ns' . ($i + 1)] = [ + 'host' => (string)$domainData->reply->nameservers->nameserver[$i], + ]; + } + } + + $contacts = $this->_allContactInfo( + (string)$domainData->reply->contact_ids->registrant, + (string)$domainData->reply->contact_ids->billing, + (string)$domainData->reply->contact_ids->administrative, + (string)$domainData->reply->contact_ids->technical + ); + + $info = DomainResult::create([ + 'id' => 'N/A', + 'domain' => $domainName, + 'statuses' => [(string)$domainData->reply->status], + 'locked' => (string)$domainData->reply->locked == 'Yes' ? true : false, + 'renew' => (string)$domainData->reply->auto_renew == 'Yes' ? true : false, + 'registrant' => $contacts['registrant'], + 'billing' => $contacts['billing'], + 'admin' => $contacts['administrative'], + 'tech' => $contacts['technical'], + 'ns' => $ns, + 'created_at' => Utils::formatDate((string)$domainData->reply->created), + 'updated_at' => null, + 'expires_at' => Utils::formatDate((string)$domainData->reply->expires), + ])->setMessage($msg); + + if ($assertActive && !in_array('Active', $info->statuses)) { + throw $this->errorResult('Domain name not active', $info->toArray()); + } + + return $info; + } + + protected function _getEppCode(string $domainName): string + { + $domainData = $this->_callApi( + [ + 'domain' => $domainName, + ], + 'retrieveAuthCode' + ); + + return 'The authorization code has been sent to the admin contact'; + } + + protected function _parseContactInfo(array $contact): ContactResult + { + return ContactResult::create(array_map(fn ($value) => in_array($value, ['', '-'], true) ? null : $value, [ + 'id' => $contact['contact_id'], + 'name' => $contact['first_name'] . ' ' . $contact['last_name'], + 'email' => $contact['email'], + 'phone' => Utils::localPhoneToInternational($contact['phone'], $contact['country'], false), + 'organisation' => !empty($contact['company']) ? $contact['company'] : '', + 'address1' => $contact['address'], + 'city' => $contact['city'], + 'state' => Utils::stateCodeToName($contact['country'], $contact['state']), + 'postcode' => $contact['zip'], + 'country_code' => $contact['country'], + ])); + } + + protected function _contactInfoPromise(string $contactId): PromiseInterface + { + if (isset($this->contactIds[$contactId])) { + return new FulfilledPromise($this->contactIds[$contactId]); + } + + return $this->_callApiPromise( + ['contact_id' => $contactId], + 'contactList' + )->then(function (SimpleXMLElement $contactData) use ($contactId) { + $contactJson = json_encode($contactData->reply->contact); + return $this->contactIds[$contactId] = $this->_parseContactInfo(json_decode($contactJson, true)); + }); + } + + protected function _contactInfo(string $contactID): ContactResult + { + return $this->_contactInfoPromise($contactID)->wait(); + } + + /** + * @return ContactResult[] [registrant, billing, administrative, technical] + */ + protected function _allContactInfo(string $registrantId, string $billingId, string $adminId, string $techId): array + { + $contacts = collect(func_get_args())->unique()->mapWithKeys(function ($contactId) { + return [$contactId => $this->_contactInfo($contactId)]; + }); + + $promises = [ + 'registrant' => $contacts->get($registrantId), + 'billing' => $contacts->get($billingId), + 'administrative' => $contacts->get($adminId), + 'technical' => $contacts->get($techId), + ]; + + return PromiseUtils::all($promises)->wait(); + } + + + protected function formatDate(?string $date): ?string + { + if (!isset($date)) { + return $date; + } + return Carbon::parse((int) $date)->toDateTimeString(); + } + + /** + * Renew domain + * + * @param string $domainName + * @return boolean + */ + protected function _renewDomain(string $domainName, int $renew_years): void + { + $this->_callApi( + [ + 'domain' => $domainName, + 'years' => $renew_years, + 'invoice-option' => 'NoInvoice', + ], + 'renewDomain' + ); + } + + /** + * Normalize a given contact address post code to satisfy nominet + * requirements. If a GB postcode is given, this method will ensure a space + * is inserted in the correct place. + * + * @param string $postCode Postal code e.g., SW152QT + * @param string $countryCode 2-letter iso code e.g., GB + * + * @return string Post code e.g., SW15 2QT + */ + protected function normalizePostCode(?string $postCode, ?string $countryCode = 'GB'): ?string + { + if (!isset($postCode) || !isset($countryCode) || $this->normalizeCountryCode($countryCode) !== 'GB') { + return $postCode; + } + + return preg_replace( + '/^([a-z]{1,2}[0-9][a-z0-9]?) ?([0-9][a-z]{2})$/i', + '${1} ${2}', + $postCode + ); + } + + protected function normalizeCountryCode(string $countryCode): string + { + return Utils::normalizeCountryCode($countryCode); + } + + protected function http(): Client + { + if ($this->client) { + return $this->client; + } + + return $this->client = new Client([ + 'base_uri' => 'https://www.namesilo.com/api/', + 'handler' => $this->getGuzzleHandlerStack(boolval($this->configuration->debug)), + ]); + } + + /** + * @param string $domain + * @param string $contactId + * @param string $associateType + * @return void + */ + private function _associateContact(string $domain, string $contactId, string $associateType): void + { + $this->_callApi([ + 'domain' => $domain, + $associateType => $contactId, + ], 'contactDomainAssociate'); + } + + private function _getRegisteredNameServers(string $domainName): \SimpleXMLElement + { + return $this->_callApi([ + 'domain' => $domainName, + ], 'listRegisteredNameServers'); + } + + private function _addRegisteredNameServer(RegisterDomainParams $params) + { + $data = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $domainParts = explode('.', Arr::get($params, 'nameservers.ns' . $i . '.host')); + $tld = array_pop($domainParts); + $data['domain'] = array_pop($domainParts) . '.' . $tld; + $data['new_host'] = implode('.', $domainParts); + if (!Arr::has($params, 'nameservers.ns' . $i . '.ip') || Arr::get($params, 'nameservers.ns' . $i . '.ip') == null) { + continue; + } + $data['ip1'] = Arr::get($params, 'nameservers.ns' . $i . '.ip'); + $this->_callApi( + $data, + 'addRegisteredNameServer' + ); + } + } + } + + private function _updateRegisteredNameServer(UpdateNameserversParams $params) + { + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + $data = []; + $data['domain'] = $domain; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $data['ns' . $i] = Arr::get($params, 'ns' . $i . '.host'); + } + } + $this->_callApi( + $data, + 'changeNameServers' + ); + } + + /** + * @param string $status + * @return string|null + */ + private function mapType(string $status): ?string + { + switch ($status) { + case 'Transfer Rejected': + return DomainNotification::TYPE_SUSPENDED; + case 'Transfer Completed': + case 'Transfer Accepted': + return DomainNotification::TYPE_TRANSFER_IN; + case 'Pending Reply from Administrative Contact': + case 'Pending at Registry': + case 'Domain has a pendingTransfer status': + return DomainNotification::TYPE_TRANSFER_OUT; + case 'Domain has a pendingDelete status': + return DomainNotification::TYPE_DELETED; + } + + return null; + } + + protected function transferStatusInProgress(string $status): bool + { + return in_array($status, [ + 'Retrieving Administrative Contact Email', + 'Pending Reply from Administrative Contact', + 'Transfer Accepted', + 'Pending at Registry', + 'Approved', + 'Approved (Auto)', + 'Transfer Completed', + 'Checking Domain Status', + 'Retrieving Administrative Contact Email (2)', + 'Submitting Transfer Request to Registry', + 'Domain has a pendingTransfer status', + ]); + } + + protected function transferStatusRejected(string $status): bool + { + return in_array($status, [ + 'Missing Authorization Code', + 'Transfer Rejected', + 'Transfer Timed Out', + 'Registry Transfer Request Failed', + 'Registrar Rejected', + 'Incorrect Authorization Code', + 'Domain is Locked', + 'Domain is Private', + 'On Hold - Created in last 60 days', + 'On Hold - Transferred in last 60 days', + 'Registry Rejected', + 'Domain Transferred Elsewhere', + 'User Cancelled', + 'Domain has a pendingDelete status', + 'Time Out', + ]); + } +} diff --git a/src/NetEarthOne/Provider.php b/src/NetEarthOne/Provider.php new file mode 100644 index 0000000..f873c7b --- /dev/null +++ b/src/NetEarthOne/Provider.php @@ -0,0 +1,23 @@ +setName('NetEarthOne') + ->setDescription( + 'NetEarthOne offers a one-stop platform which allows ' + . 'you, your Resellers and your Customers to buy, sell and ' + . 'manage various gTLD and ccTLD Domain Names.' + ) + ->setLogoUrl('https://api.upmind.io/images/logos/provision/netearthone-logo_2x.png'); + } +} diff --git a/src/Nira/Data/Configuration.php b/src/Nira/Data/Configuration.php new file mode 100644 index 0000000..11b1f54 --- /dev/null +++ b/src/Nira/Data/Configuration.php @@ -0,0 +1,22 @@ + ['required', 'string', 'min:1'], + 'epp_password' => ['required', 'string', 'min:6'], + ]); + } +} diff --git a/src/Nira/Provider.php b/src/Nira/Provider.php new file mode 100644 index 0000000..19625f8 --- /dev/null +++ b/src/Nira/Provider.php @@ -0,0 +1,49 @@ +configuration = $configuration; + } + + protected function getSupportedTlds(): array + { + return ['ng']; + } + + protected function makeClient(): Client + { + return new Client( + $this->configuration->epp_username, + $this->configuration->epp_password, + 'registry.nic.net.ng', + 700, + __DIR__ . '/cert.pem', + $this->getLogger() + ); + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('NIRA') + ->setDescription('Register, transfer, renew and manage NIRA domains'); + } +} diff --git a/src/Nira/cert.pem b/src/Nira/cert.pem new file mode 100644 index 0000000..9084fde --- /dev/null +++ b/src/Nira/cert.pem @@ -0,0 +1,58 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqMcejxRrpO +c2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4PiUifsz0 +Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi8eABg97A +0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs/pQPEX6q +IC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XDx+KUnv+D +6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABAoIBADaynUAqyjH2LGMB +mKbe133Zg0tSgFuOWaBuOnUHQkMzjuLOMX3VrBVBBRQrD93FEQNvYbud/LWk5RmK +yHafA2RrA43R1diX3NUqJhErTmMSwZuoy6d9zZlLH8IpqG/3tj0Ztghx6BVGN0GQ +v40IJ0zFeq5X5TZyq1tnTAFld18W/E+4L5DmAn4ROoIscPh/u7ua/wrD1D4ooO5h +NWMXvEuA4o60TGeQCjzssEVzwgUVPKQBqhhB2kQrvpNqCKV/cLuBdqGb2VX9+hXm +zzIkGIkDSQ5+NhcfnBHVirsH3KNKX/m8z2GiBttuIN/OQqM84tN3kKTDe4aN0f9i +7lidZaECgYEA51HsCKTBOh9OZTu5lT3Ev1dcerSIKSlDk80TnmBBsNr8ctIQSID5 +28ARU1L+CH7+06XIEbcBThCHrJNMBA+PE/Ykqw64zzXk7ik1tCly/hFSVKpZD6Wp +lEjlYESCqL8UKg9MoVjc8cHnLf4smG2X+SQMDaqneIJZy8neuhsS4bUCgYEA2mvX +eHYQNUit05hweUR7VtZxuSLSOvyHvAxrEdG22eYMLk6LF9y9IC2yWpz/cdWHMqLq +3unA42OS4/tSqxoSKYBSdoyFGWX5NltFCzZPXd78D8FVz8U626X28L21bcgVcwCd +EJ8aU+zhzqixSkC9dxDX6HJcxfbaVbucNoSca9kCgYEAwHSaSr64vSDa2sMMLq0L +ip6mpLibKJPaU5gmIHi5buljbCx1u70DJN/yCj9cd7khTvn5MTPvdAGwv9Z1QlOn +mNYLv/4pqMyQQc4rjk+GCvhiZWqtWqVcJ7FWlfeqNbd0kWHVQdBrUwEe1FdKxy83 +Z+Oj26MGXu8kwracBn8MAJkCgYAIHmIP9DN+B4mOh+gGWelLvQTVINo3nxNchgmk +y+rEBq0FO54n8OiGvawXeiZ0kL9JvoyEZKPqz9Sx7LGR8pIiQMbP6UE5RHUS9CmI +1Sf2EUfFPiZ2Zppdd7nKEQMhZYKGl8s+xusvm2p5SAPvAqEIP/QGi9mu8hIDhcm0 +rREzYQKBgDfe4Z+bVD13ASXtJ+C7gOmaIQTQI/68DIT2eFR6IOc+LM1K1o8u/KQy +NNWaOIiVe7oXWOg59HmMH7bkra/I/qBb4Fzb4QOhLxXlMthTmG2dJBrQJMLoirHX +BIWiYoeRv3V+H6p4oGP+g4LAfYleEcAUij6YQLy9oUwbusZlFeWs +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFZjCCBE6gAwIBAgIQK+MtAo2IygRd0TCN2bO5QDANBgkqhkiG9w0BAQsFADCB +kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV +BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD +QTAeFw0xNjA5MjIwMDAwMDBaFw0xNzA5MjIyMzU5NTlaMF4xITAfBgNVBAsTGERv +bWFpbiBDb250cm9sIFZhbGlkYXRlZDEhMB8GA1UECxMYUG9zaXRpdmVTU0wgTXVs +dGktRG9tYWluMRYwFAYDVQQDEw1nYXJhbm50b3IuY29tMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqM +cejxRrpOc2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4 +PiUifsz0Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi +8eABg97A0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs +/pQPEX6qIC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XD +x+KUnv+D6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABo4IB6zCCAecw +HwYDVR0jBBgwFoAUkK9qOpRaC9iQ6hJWc99DtDoo2ucwHQYDVR0OBBYEFEk8baOc +uKUuA4ORiEI3AXdZGXBYMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0G +A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBPBgNVHSAESDBGMDoGCysGAQQB +sjEBAgIHMCswKQYIKwYBBQUHAgEWHWh0dHBzOi8vc2VjdXJlLmNvbW9kby5jb20v +Q1BTMAgGBmeBDAECATBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLmNvbW9k +b2NhLmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0Eu +Y3JsMIGFBggrBgEFBQcBAQR5MHcwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuY29t +b2RvY2EuY29tL0NPTU9ET1JTQURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJD +QS5jcnQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTA5BgNV +HREEMjAwgg1nYXJhbm50b3IuY29tgg8qLmdhcmFubnRvci5jb22CDiouZ2FyYW5u +dG9yLm5nMA0GCSqGSIb3DQEBCwUAA4IBAQBcvhvND3DnYZEytIWIDy/LbrkfzVOQ +GvGxsMQsNYLmYmsmnYLA4cBlZ2DIZbWehfMPXAbZgre2+Y8yOFZOzRR848hU1Kka +7Tf/XUMr/YEV+SwRiEjL3cX1HLXPXzB0DhAG/3rsK+ANePxWNCXLK5riBKSN1C45 +1cahmHRw4+DNccUTZAX6yOhWmNtgvYwbtAqxZfkIivaB4J7nuolG5d/FHMOpEqwL +TEBWT0I5fuGX2XO94Xw9zyEjhPF0Psiqzi+6Umv8lyelLr/NI3b3g2kfcvoM6KRe +6K3NgNT8eJGH4Tf2OM4AGd20FwqWwlVe86Nc9qYJQsGmMwzU2sfem+AQ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/Nominet/Data/NominetConfiguration.php b/src/Nominet/Data/NominetConfiguration.php new file mode 100644 index 0000000..edc2946 --- /dev/null +++ b/src/Nominet/Data/NominetConfiguration.php @@ -0,0 +1,29 @@ + ['required', 'string', 'min:2', 'max:16'], + 'password' => ['required', 'string', 'min:6'], + 'sandbox' => ['boolean'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/Nominet/EppExtension/NominetConnection.php b/src/Nominet/EppExtension/NominetConnection.php new file mode 100644 index 0000000..e34b767 --- /dev/null +++ b/src/Nominet/EppExtension/NominetConnection.php @@ -0,0 +1,75 @@ +addExtension('std-unrenew-1.0', 'http://www.nominet.org.uk/epp/xml/std-unrenew-1.0'); + $this->addExtension('std-release-1.0', 'http://www.nominet.org.uk/epp/xml/std-release-1.0'); + $this->addExtension('std-handshake-1.0', 'http://www.nominet.org.uk/epp/xml/std-handshake-1.0'); + $this->addExtension('contact-nom-ext-1.0', 'http://www.nominet.org.uk/epp/xml/contact-nom-ext-1.0'); + $this->addExtension('std-notifications-1.2', 'http://www.nominet.org.uk/epp/xml/std-notifications-1.2'); + + $this->addCommandResponse(eppCreateContactRequest::class, eppCreateContactResponse::class); + $this->addCommandResponse(eppInfoContactRequest::class, eppInfoContactResponse::class); + $this->addCommandResponse(eppPollRequest::class, eppPollResponse::class); + $this->addCommandResponse(eppReleaseRequest::class, eppReleaseResponse::class); + } + + /** + * Set a PSR-3 logger. + */ + public function setPsrLogger(?LoggerInterface $logger): void + { + $this->logger = $logger; + if (isset($logger)) { + $this->logFile = '/dev/null'; + } + } + + /** + * Writes a log message to the log file or PSR-3 logger. + * + * @inheritdoc + */ + public function writeLog($text, $action) + { + if ($this->logging && isset($this->logger)) { + $message = $text; + $message = $this->hideTextBetween($message, '', ''); + // Hide password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide new password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide domain password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + // Hide contact password in the logging + $message = $this->hideTextBetween($message, '', ''); + $message = $this->hideTextBetween($message, ''); + + $this->logger->debug(sprintf("Nominet [%s]:\n %s", $action, trim($message))); + } + + parent::writeLog($text, $action); + } +} diff --git a/src/Nominet/EppExtension/eppCreateContactRequest.php b/src/Nominet/EppExtension/eppCreateContactRequest.php new file mode 100644 index 0000000..0775f2c --- /dev/null +++ b/src/Nominet/EppExtension/eppCreateContactRequest.php @@ -0,0 +1,78 @@ +extension) { + $this->getCommand()->removeChild($this->getExtension()); + $this->extension = null; + } + + if (empty($type)) { + return; + } + + // set epp stuff + + $this->getEpp()->setAttribute('xmlns', 'urn:ietf:params:xml:ns:epp-1.0'); + $this->getEpp()->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $this->getEpp()->setAttribute('xsi:schemaLocation', 'urn:ietf:params:xml:ns:epp-1.0 epp-1.0.xsd'); + + // set contact create stuff + + /** @var DOMElement $create */ + $create = $this->getCommand()->getElementsByTagName('create')->item(0); + /** @var DOMElement $contactCreate */ + $contactCreate = $create->getElementsByTagName('contact:create')->item(0); + + $contactCreate->setAttribute('xmlns:contact', 'urn:ietf:params:xml:ns:contact-1.0'); + $contactCreate->setAttribute('xsi:schemaLocation', 'urn:ietf:params:xml:ns:contact-1.0 contact-1.0.xsd'); + + // create + set extension + + $contactExtension = $this->createElement('contact-ext:create'); + $contactExtension->setAttribute('xmlns:contact-ext', 'http://www.nominet.org.uk/epp/xml/contact-nom-ext-1.0'); + $contactExtension->setAttribute('xsi:schemaLocation', 'http://www.nominet.org.uk/epp/xml/contact-nom-ext-1.0 contact-nom-ext-1.0.xsd'); + + $contactExtension->appendChild($this->createElement('contact-ext:type', $type)); + + if ($tradeName) { + $contactExtension->appendChild($this->createElement('contact-ext:trad-name', $tradeName)); + } + + if ($companyNumber) { + $contactExtension->appendChild($this->createElement('contact-ext:co-no', $companyNumber)); + } + + $this->getExtension()->appendChild($contactExtension); + + // move clTRID to after the extension + $clTRID = $this->getCommand()->getElementsByTagName('clTRID')->item(0); + $this->getCommand()->removeChild($clTRID); + $this->getCommand()->appendChild($clTRID); + } +} diff --git a/src/Nominet/EppExtension/eppHandshakeRequest.php b/src/Nominet/EppExtension/eppHandshakeRequest.php new file mode 100644 index 0000000..7cc296b --- /dev/null +++ b/src/Nominet/EppExtension/eppHandshakeRequest.php @@ -0,0 +1,29 @@ +getEpp()->setAttribute('xmlns', 'urn:ietf:params:xml:ns:epp-1.0'); + $this->getEpp()->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $this->getEpp()->setAttribute('xsi:schemaLocation', 'urn:ietf:params:xml:ns:epp-1.0 epp-1.0.xsd'); + + $update = $this->createElement('update'); + $handshake = $this->createElement('h:accept'); + $handshake->setAttribute('xmlns:h', 'http://www.nominet.org.uk/epp/xml/std-handshake-1.0'); + $handshake->setAttribute('xsi:schemaLocation', 'http://www.nominet.org.uk/epp/xml/std-handshake-1.0 std-handshake-1.0.xsd'); + + $handshake->appendChild($this->createElement('h:caseId', $caseId)); + $handshake->appendChild($this->createElement('h:registrant', $registrarId)); + + $update->appendChild($handshake); + $this->getCommand()->appendChild($update); + } +} diff --git a/src/Nominet/EppExtension/eppHandshakeResponse.php b/src/Nominet/EppExtension/eppHandshakeResponse.php new file mode 100644 index 0000000..16b9d10 --- /dev/null +++ b/src/Nominet/EppExtension/eppHandshakeResponse.php @@ -0,0 +1,40 @@ +queryPath('/epp:epp/epp:response/epp:resData/h:hanData/h:caseId'); + } + + /** + * @return array + */ + public function getDomains() + { + return $this->queryPath('/epp:epp/epp:response/epp:resData/h:hanData/h:domainListData/*'); + } +} diff --git a/src/Nominet/EppExtension/eppInfoContactResponse.php b/src/Nominet/EppExtension/eppInfoContactResponse.php new file mode 100644 index 0000000..f37333a --- /dev/null +++ b/src/Nominet/EppExtension/eppInfoContactResponse.php @@ -0,0 +1,42 @@ + $this->getNominetContactValue('type'), + 'trad-name' => $this->getNominetContactValue('trad-name'), + 'co-no' => $this->getNominetContactValue('co-no'), + 'opt-out' => $this->getNominetContactValue('opt-out'), + ]; + } + + public function getNominetContactValue($name): ?string + { + /** + * @var DOMElement $element + */ + $element = $this->getExtensionElement()->getElementsByTagName($name)->item(0); + return $element->textContent ?? null; + } + + public function getExtensionElement(): ?DOMElement + { + return $this->getElementsByTagName('epp')->item(0) + ->getElementsByTagName('response')->item(0) + ->getElementsByTagName('extension')->item(0); + } +} diff --git a/src/Nominet/EppExtension/eppPollResponse.php b/src/Nominet/EppExtension/eppPollResponse.php new file mode 100644 index 0000000..d672936 --- /dev/null +++ b/src/Nominet/EppExtension/eppPollResponse.php @@ -0,0 +1,106 @@ +notificationType !== false) { + return $this->notificationType; + } + + $xpath = $this->xPath(); + + /** + * @link https://registrars.nominet.uk/uk-namespace/registration-and-domain-management/registration-systems/epp/epp-notifications/#registrar-change + */ + $res = $xpath->query('/epp:epp/epp:response/epp:resData/std-notifications-1.2:rcData'); + if ($res instanceof DOMNodeList && $res->length > 0) { + return $this->notificationType = DomainNotification::TYPE_TRANSFER_IN; + } + + /** + * @link https://registrars.nominet.uk/uk-namespace/registration-and-domain-management/registration-systems/epp/epp-notifications/#poor-quality-data + */ + $res = $xpath->query('/epp:epp/epp:response/epp:resData/std-notifications-1.2:processData'); + if ($res instanceof DOMNodeList && $res->length > 0) { + return $this->notificationType = DomainNotification::TYPE_DATA_QUALITY; + } + + /** + * @link https://registrars.nominet.uk/uk-namespace/registration-and-domain-management/registration-systems/epp/epp-notifications/#domain-cancelled + */ + $res = $xpath->query('/epp:epp/epp:response/epp:resData/std-notifications-1.2:cancData'); + if ($res instanceof DOMNodeList && $res->length > 0) { + return $this->notificationType = DomainNotification::TYPE_DELETED; + } + + /** + * @link https://registrars.nominet.uk/uk-namespace/registration-and-domain-management/registration-systems/epp/epp-notifications/#domains-released + */ + $res = $xpath->query('/epp:epp/epp:response/epp:resData/std-notifications-1.2:relData'); + if ($res instanceof DOMNodeList && $res->length > 0) { + if (Str::contains($this->getMessage(), ['Released'])) { //success + return $this->notificationType = DomainNotification::TYPE_TRANSFER_OUT; + } + + if (Str::contains($this->getMessage(), ['Rejected'])) { // failure + return $this->notificationType = null; + } + } + + return $this->notificationType = null; + } + + /** + * Determine the domain names which are the subject of this notification message. + * + * @return string[] + */ + public function getDomains(): array + { + $xpath = $this->xPath(); + + /** + * Attempt to find domain name(s) in the following query paths. + */ + $queryPaths = [ + '//std-notifications-1.2:domainName', + '//domain:name', + ]; + + foreach ($queryPaths as $path) { + $nodeList = $xpath->query($path); + if ($nodeList instanceof DOMNodeList && $nodeList->length > 0) { + return collect($nodeList)->map(function (DOMElement $element) { + return trim($element->textContent); + })->all(); + } + } + + throw new RuntimeException('Unable to determine domain name(s)'); + } +} diff --git a/src/Nominet/EppExtension/eppReleaseRequest.php b/src/Nominet/EppExtension/eppReleaseRequest.php new file mode 100644 index 0000000..3145812 --- /dev/null +++ b/src/Nominet/EppExtension/eppReleaseRequest.php @@ -0,0 +1,52 @@ +getEpp()->setAttribute('xmlns', 'urn:ietf:params:xml:ns:epp-1.0'); + $this->getEpp()->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance'); + $this->getEpp()->setAttribute('xsi:schemaLocation', 'urn:ietf:params:xml:ns:epp-1.0 epp-1.0.xsd'); + // $this->addExtension('xmlns:command-ext-domain', 'http://www.metaregistrar.com/epp/command-ext-domain-1.0'); + // $command = $this->createElement('command'); + $update = $this->createElement('update'); + $release = $this->createElement('r:release'); + $release->setAttribute('xmlns:r', 'http://www.nominet.org.uk/epp/xml/std-release-1.0'); + $release->setAttribute('xsi:schemaLocation', 'http://www.nominet.org.uk/epp/xml/std-release-1.0 std-release-1.0.xsd'); + if ($object->getDomainname()) { + $domain = $this->createElement('r:domainName', $object->getDomainname()); + } else { + throw new eppException("Missing domain!"); + } + $release->appendChild($domain); + $release->appendChild($this->createElement('r:registrarTag', $newTag)); + + $update->appendChild($release); + // $update->appendChild($this->createElement('clTRID', "ABC-12345")); + $this->getCommand()->appendChild($update); + // $this->addSessionId(); + // $domainChild->appendChild($release); + // $commandExt = $this->createElement('command-ext:command-ext'); + // $commandExt->appendChild($domainChild); + // $this->getExtension()->appendChild($commandExt); + } +} diff --git a/src/Nominet/EppExtension/eppReleaseResponse.php b/src/Nominet/EppExtension/eppReleaseResponse.php new file mode 100644 index 0000000..2a9a428 --- /dev/null +++ b/src/Nominet/EppExtension/eppReleaseResponse.php @@ -0,0 +1,46 @@ +queryPath('/epp:epp/epp:response/epp:resData/r:releasePending'); + } + + // /** + // * @return null|string + // */ + // public function getMsg() { + // return $this->queryPath('/epp:epp/epp:response/response:result/result:msg'); + // } + + // /** + // * @return null|string + // */ + // public function getDetails() { + // return $this->queryPath('/epp:epp/epp:response/response:resData/resData:r:releasePending'); + // } +} diff --git a/src/Nominet/Provider.php b/src/Nominet/Provider.php new file mode 100644 index 0000000..180abbd --- /dev/null +++ b/src/Nominet/Provider.php @@ -0,0 +1,830 @@ + 'ns1.nominet.org.uk'], + ['host' => 'ns2.nominet.org.uk'] + ]; + + /** + * Max positions for nameservers + */ + private const MAX_CUSTOM_NAMESERVERS = 5; + + public function __construct(NominetConfiguration $configuration) + { + $this->configuration = $configuration; + } + + public function __destruct() + { + try { + if (isset($this->connection)) { + $this->connection->disconnect(); + } + } catch (Throwable $e) { + // ignore - we're probably already disconnected + } + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('Nominet') + ->setDescription('Register, transfer, renew and manage .uk domain') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/nominet-logo@2x.png'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + $domains = Arr::get($params, 'domains'); + + try { + $checkDomains = []; + foreach ($domains as $domain) { + $checkDomains[] = Utils::getDomain(Arr::get($domain, 'sld'), Arr::get($domain, 'tld')); + } + + $checkedDomains = $this->_checkDomains($checkDomains); + $domainsIt = count($domains); + $responseDomains = []; + while (--$domainsIt >= 0) { + $responseDomains[] = array_merge($domains[$domainsIt], $checkedDomains[$domainsIt]); + } + + return $this->okResult('Domains checked.', $responseDomains); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function poll(PollParams $params): PollResult + { + $connection = $this->epp(); + + $since = $params->after_date ? Carbon::parse($params->after_date) : null; + $notifications = []; + $countRemaining = 0; + + /** + * Start a timer because there may be 1000s of irrelevant messages and we should try and avoid a timeout. + */ + $timeLimit = 60; // 60 seconds + $startTime = time(); + + try { + while (count($notifications) < $params->limit && (time() - $startTime) < $timeLimit) { + // get oldest message from queue + /** @var eppPollResponse $pollResponse */ + $pollResponse = $connection->request(new eppPollRequest(eppPollRequest::POLL_REQ, 0)); + $countRemaining = $pollResponse->getMessageCount(); + + if ($pollResponse->getResultCode() === eppResponse::RESULT_NO_MESSAGES) { + break; + } + + $messageId = $pollResponse->getMessageId(); + $type = $pollResponse->getNotificationType(); + $message = $pollResponse->getMessage() ?: 'Domain Notification'; + $domains = $pollResponse->getDomains(); + $messageDateTime = Carbon::parse($pollResponse->getMessageDate()); + + // send ack request to purge this message from the queue + $connection->request(new eppPollRequest(eppPollRequest::POLL_ACK, $messageId)); + + if (is_null($type)) { + // this message is irrelevant + continue; + } + + if (isset($since) && $messageDateTime->lessThan($since)) { + // this message is too old + continue; + } + + $notifications[] = DomainNotification::create() + ->setId($messageId) + ->setType($type) + ->setMessage($message) + ->setDomains($domains) + ->setCreatedAt($messageDateTime) + ->setExtra(['xml' => $pollResponse->saveXML()]); + } + } catch (\Throwable $e) { + $data = []; + + if (isset($pollResponse)) { + $data['last_xml'] = $pollResponse->saveXML(); + } + + return $this->errorResult('Error encountered while polling for domain notifications', $data, [], $e); + } + + return new PollResult([ + 'count_remaining' => $countRemaining, + 'notifications' => $notifications, + ]); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + try { + if (Arr::has($params, 'registrant.id')) { + $contactID = $params->registrant->id; + } else { + $contactID = $this->_createContact( + $params->registrant->register->email, + $params->registrant->register->phone, + $params->registrant->register->name ?: $params->registrant->register->organisation, + $params->registrant->register->organisation ?: $params->registrant->register->name, + $params->registrant->register->address1, + $params->registrant->register->postcode, + $params->registrant->register->city, + $params->registrant->register->country_code, + 'IND' // hard-code all new registrations to IND for now until we support dynamic ccTLD fields + ); + } + + // Determine which name servers to use + $nameServers = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $nameServers[] = Arr::get($params, 'nameservers.ns' . $i)->toArray(); + } + } + + // Use the default name servers in case we didn't provide our own + $nameServers = $nameServers ?: self::DEFAULT_NAMESERVERS; + + $this->_createDomain($domain, Arr::get($params, 'renew_years', 1), $contactID, $nameServers); + + return $this->_getDomain($domain, 'Domain registered successfully'); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function transfer(TransferParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + try { + return $this->_getDomain($domain, 'Domain active in registrar account'); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + try { + $this->_renewDomain($domain, intval($params->renew_years)); + return $this->_getDomain($domain, 'The expire date is extended.'); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + try { + return $this->_getDomain($domain); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $nameservers = array_values(array_filter([ + $params->ns1, + $params->ns2, + $params->ns3, + $params->ns4, + $params->ns5, + ])); + + try { + $this->_updateDomain($domain, null, $nameservers); + + $returnNameservers = []; + foreach ($nameservers as $i => $ns) { + $returnNameservers['ns' . ($i + 1)] = $ns; + } + + return NameserversResult::create($returnNameservers) + ->setMessage('Nameservers updated successfully'); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function getEppCode(EppParams $params): EppCodeResult + { + throw $this->errorResult('Operation not supported for this type of domain name'); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + try { + $domain = new eppDomain($domainName); + $transfer = new eppReleaseRequest($domain, $params->ips_tag); + + /** @var eppReleaseResponse */ + $result = $this->epp()->request($transfer); + return $this->okResult($result->getResultMessage()); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + + try { + $domainInfo = $this->_getDomain($domainName); + + $this->_updateContact( + $domainInfo->registrant->id, + $email = $params->contact->email, + $phone = $params->contact->phone, + $name = $params->contact->name ?: $params->contact->organisation, + $organisation = $params->contact->organisation ?: $params->contact->name, + $address1 = $params->contact->address1, + $postcode = $params->contact->postcode, + $city = $params->contact->city, + $countryCode = $params->contact->country_code, + ); + + return ContactResult::create([ + 'name' => $name, + 'organisation' => $organisation, + 'email' => $email, + 'phone' => $phone, + 'address1' => $address1, + 'city' => $city, + 'postcode' => $postcode, + 'country_code' => $countryCode, + ])->setMessage('Registrant details updated'); + } catch (eppException $e) { + $this->_eppExceptionHandler($e, $params->toArray()); + } + } + + public function setLock(LockParams $params): DomainResult + { + throw $this->errorResult('Operation not supported'); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('Operation not supported'); + } + + /** + * Check availability for domain names + * + * @param array $domains + * @return array + */ + protected function _checkDomains(array $domains): array + { + $result = []; + $check = new eppCheckDomainRequest($domains); + + /** @var eppCheckDomainResponse */ + $response = $this->epp()->request($check); + $checks = $response->getCheckedDomains(); + + foreach ($domains as $domain) { + foreach ($checks as $checkK => $check) { + if ($domain == $check['domainname'] || strtolower($domain) == $check['domainname']) { + $result[] = [ + 'domain' => $check['domainname'], + 'available' => $check['available'], + 'reason' => $check['reason'], + ]; + + unset($checks[$checkK]); + continue 2; + } + } + } + + return $result; + } + + /** + * Domain creation + * + * @param string $domainName + * @param int $period + * @param string $registrantID + * @param array|null $nameservers [['host' => 'ns1.ns.com', 'ip' => '1.2.3.4', 'status' => '2']] + * @return void + */ + protected function _createDomain( + string $domainName, + int $period, + string $registrantID, + ?array $nameservers + ): void { + $domain = new eppDomain($domainName, $registrantID); + + $domain->setRegistrant(new eppContactHandle($registrantID)); + $domain->setAuthorisationCode('not_used'); + if (is_array($nameservers) && count($nameservers) >= 1) { + $domain = $this->setNameservers($domain, $nameservers); + } + + $domain->setPeriod($period); + $domain->setPeriodUnit('y'); + + $create = new eppCreateDomainRequest($domain); + /** @var eppCreateDomainResponse */ + $response = $this->epp()->request($create); + } + + protected function setNameservers( + eppDomain $domain, + array $nameservers + ): eppDomain { + $hosts = []; + foreach ($nameservers as $nameserver) { + $hosts[] = $nameserver['host']; + } + + $uncreatedHosts = $this->checkUncreatedHosts($hosts); + + foreach ($nameservers as $nameserver) { + if (!empty($uncreatedHosts[$nameserver['host']])) { + $this->createHost($nameserver['host'], $nameserver['ip'] ?? null); + } + + $domain->addHost(new eppHost($nameserver['host'])); + } + + return $domain; + } + + /** + * Creates contact and returns its ID + * + * @param string $email + * @param string $telephone + * @param string $name + * @param string $organization + * @param string $address + * @param string $postcode + * @param string $city + * @param string $countryCode + * @return string + */ + protected function _createContact( + string $email, + string $telephone, + string $name, + string $organization, + string $address, + string $postcode, + string $city, + string $countryCode, + string $nominetContactType = null, + string $tradingName = null, + string $companyNumber = null + ): string { + $telephone = Utils::internationalPhoneToEpp($telephone); + $countryCode = $this->normalizeCountryCode($countryCode); + $postcode = $this->normalizePostCode($postcode, $countryCode); + + $postalInfo = new eppContactPostalInfo( + $name, + $city, + $countryCode, + $organization, + $address, + null, + $postcode + ); + $contactInfo = new eppContact($postalInfo, $email, $telephone); + + $contact = new NominetEppCreateContactRequest($contactInfo); + if ($nominetContactType) { + $contact->setNominetContactType($nominetContactType, $tradingName, $companyNumber); + } + + /** @var eppCreateContactResponse $response */ + $response = $this->epp()->request($contact); + return $response->getContactId(); + } + + /** + * Implements all changes to domain - contacts and nameservers + * + * @param string $domainName + * @param string|null $registrant + * @param array|null $nameservers + * @return string + */ + protected function _updateDomain( + string $domainName, + ?string $registrantId = null, + ?array $nameservers = null + ): string { + // In the UpdateDomain command you can set or add parameters + // - Registrant is always set (you can only have one registrant) + // - Admin, Tech, Billing contacts are Added (you can have multiple contacts, don't forget to remove the old ones) + // - Nameservers are Added (you can have multiple nameservers, don't forget to remove the old ones) + + // If new nameservers are given, get the old ones to remove them + if (isset($nameservers)) { + $info = new eppInfoDomainRequest(new eppDomain($domainName)); + /** @var eppInfoDomainResponse $response */ + $response = $this->epp()->request($info); + + if ($oldNameservers = $response->getDomainNameservers()) { + $removeInfo = new eppDomain($domainName); + foreach ($oldNameservers as $ns) { + $removeInfo->addHost(new eppHost($ns->getHostname())); + } + } + + $addInfo = new eppDomain($domainName); + $addInfo = $this->setNameservers($addInfo, $nameservers); + } + + if (isset($registrantId)) { + $updateInfo = new eppDomain($domainName); + $updateInfo->setRegistrant(new eppContactHandle($registrantId)); + } + + $update = new eppUpdateDomainRequest( + new eppDomain($domainName), + $addInfo ?? null, + $removeInfo ?? null, + $updateInfo ?? null + ); + /** @var eppUpdateDomainResponse $response */ + $response = $this->epp()->request($update); + + return $response->getResultMessage(); + } + + protected function _getDomain( + string $domainName, + string $msg = 'Domain info obtained' + ): DomainResult { + $domain = new eppDomain($domainName); + $info = new eppInfoDomainRequest($domain, eppInfoDomainRequest::HOSTS_ALL); + + /** @var eppInfoDomainResponse */ + $response = $this->epp()->request($info); + + $returnNs = []; + $nameservers = $response->getDomainNameservers(); + if (isset($nameservers)) { + /** @var eppHost $nameserver */ + foreach ($nameservers as $i => $nameserver) { + $ips = $nameserver->getIpAddresses(); + $returnNs['ns' . ($i + 1)] = [ + "host" => trim($nameserver->getHostname(), '.'), + "ip" => isset($ips) ? array_shift($ips) : null, + ]; + } + } + + $contact = $this->_contactInfo($response->getDomainRegistrant()); + + return DomainResult::create([ + 'id' => $response->getDomainId(), + 'domain' => $response->getDomainName(), + 'statuses' => $response->getDomainStatuses() ?? [], // Not in standard response + 'registrant' => [ + 'id' => $response->getDomainRegistrant(), + 'name' => $contact->getContactName(), + 'email' => $contact->getContactEmail(), + 'phone' => $contact->getContactVoice(), + 'organisation' => $contact->getContactCompanyname(), + 'address1' => $contact->getContactStreet(), + 'city' => $contact->getContactCity(), + 'postcode' => $contact->getContactZipcode(), + 'country_code' => $contact->getContactCountrycode(), + 'extra' => $contact->getNominetContactData(), + ], + 'ns' => $returnNs, + 'created_at' => $this->formatDate($response->getDomainCreateDate()), + 'updated_at' => $this->formatDate($response->getDomainUpdateDate()) + ?? $this->formatDate($response->getDomainCreateDate()), + 'expires_at' => $this->formatDate($response->getDomainExpirationDate()), + ])->setMessage($msg); + } + + protected function _contactInfo(string $contactID): NominetEppInfoContactResponse + { + $check = new eppInfoContactRequest(new eppContactHandle($contactID), false); + return $this->epp()->request($check); + } + + protected function _updateContact( + string $contactID, + string $email, + string $telephone, + string $name, + string $organization, + string $address, + string $postcode, + string $city, + string $countryCode + ): string { + $telephone = Utils::internationalPhoneToEpp($telephone); + $countryCode = Utils::normalizeCountryCode($countryCode); + $postcode = $this->normalizePostCode($postcode, $countryCode); + + $updateInfo = new eppContact( + new eppContactPostalInfo( + $name, + $city, + $countryCode, + $organization, + $address, + null, + $postcode, + eppContact::TYPE_LOC + ), + $email, + $telephone + ); + $update = new eppUpdateContactRequest(new eppContactHandle($contactID), null, null, $updateInfo); + + /** @var eppUpdateContactResponse $response */ + $response = $this->epp()->request($update); + return $response->getResultMessage(); + } + + protected function formatDate(?string $date): ?string + { + if (!isset($date)) { + return $date; + } + + return Carbon::parse($date)->format('Y-m-d H:i:s'); + } + + /** + * Check which of the given hosts/nameservers are not created yet. + * + * @param array $hosts + * @return bool[]|array True means host needs to be created + */ + protected function checkUncreatedHosts(array $hosts): ?array + { + try { + $checkHost = []; + foreach ($hosts as $host) { + $checkHost[] = new eppHost($host); + } + + $check = new eppCheckRequest($checkHost); + /** @var eppCheckResponse $response */ + $response = $this->epp()->request($check); + + return $response->getCheckedHosts(); + } catch (eppException $e) { + return null; + } + } + + /** + * For creation of host + * + * @param string $host + * @param string|null $ip + * @return void + */ + protected function createHost(string $host, string $ip = null): void + { + $create = new eppCreateHostRequest(new eppHost($host, $ip)); + + /** @var eppCreateHostResponse */ + $response = $this->epp()->request($create); + } + + /** + * Renew domain + * + * @param string $domainName + * @param int $renew_years + * @return void + */ + protected function _renewDomain(string $domainName, int $renew_years): void + { + $domain = new eppDomain($domainName); + $info = new eppInfoDomainRequest($domain); + $domain->setPeriodUnit('y'); + $domain->setPeriod($renew_years); + + /** @var eppInfoDomainResponse $response */ + $response = $this->epp()->request($info); + $expireAt = date('Y-m-d', strtotime($response->getDomainExpirationDate())); + + $renew = new eppRenewRequest($domain, $expireAt); + + /** @var eppRenewResponse $response */ + $response = $this->epp()->request($renew); + } + + /** + * Normalize a given contact address post code to satisfy nominet + * requirements. If a GB postcode is given, this method will ensure a space + * is inserted in the correct place. + * + * @param string $postCode Postal code e.g., SW152QT + * @param string $countryCode 2-letter iso code e.g., GB + * + * @return string Post code e.g., SW15 2QT + */ + protected function normalizePostCode(?string $postCode, ?string $countryCode = 'GB'): ?string + { + if (!isset($postCode) || !isset($countryCode) || $this->normalizeCountryCode($countryCode) !== 'GB') { + return $postCode; + } + + return preg_replace( + '/^([a-z]{1,2}[0-9][a-z0-9]?) ?([0-9][a-z]{2})$/i', + '${1} ${2}', + $postCode + ); + } + + protected function normalizeCountryCode(string $countryCode): string + { + return Utils::normalizeCountryCode($countryCode); + } + + /** + * @throws ProvisionFunctionError + * @return no-return + */ + private function _eppExceptionHandler(eppException $exception, array $data = [], array $debug = []): void + { + if ($response = $exception->getResponse()) { + $debug['response_xml'] = $response->saveXML(); + } + + switch ($exception->getCode()) { + case 2001: + $errorMessage = 'Invalid request data'; + break; + default: + $errorMessage = $exception->getMessage(); + } + + throw $this->errorResult(sprintf('Registry Error: %s', $errorMessage), $data, $debug, $exception); + } + + protected function epp(): NominetConnection + { + if (isset($this->connection)) { + return $this->connection; + } + + try { + $connection = new NominetConnection(!!$this->configuration->debug); + $connection->setPsrLogger($this->getLogger()); + + $connection->setHostname( + $this->configuration->sandbox + ? 'ssl://ote-epp.nominet.org.uk' + : 'ssl://epp.nominet.org.uk' + ); + $connection->setPort(700); + $connection->setUsername( + strlen($this->configuration->ips_tag) === 2 + ? sprintf('#%s', $this->configuration->ips_tag) + : $this->configuration->ips_tag + ); + $connection->setPassword($this->configuration->password); + + // connect and authenticate + $connection->login(); + + return $this->connection = $connection; + } catch (eppException $e) { + switch ($e->getCode()) { + case 2001: + $errorMessage = 'Authentication error; check credentials'; + break; + case 2200: + $errorMessage = 'Authentication error; check credentials and whitelisted IPs'; + break; + default: + $errorMessage = 'Unexpected provider connection error'; + } + + throw $this->errorResult(sprintf('%s %s', $e->getCode(), $errorMessage), [], [], $e); + } catch (ErrorException $e) { + if (Str::containsAll($e->getMessage(), ['stream_socket_client()', 'SSL'])) { + // this usually means they've not whitelisted our IPs + $errorMessage = 'Connection error; check whitelisted IPs'; + } else { + $errorMessage = 'Unexpected provider connection error'; + } + + throw $this->errorResult($errorMessage, [], [], $e); + } + } +} diff --git a/src/OpenProvider/Data/OpenProviderConfiguration.php b/src/OpenProvider/Data/OpenProviderConfiguration.php new file mode 100644 index 0000000..df0884b --- /dev/null +++ b/src/OpenProvider/Data/OpenProviderConfiguration.php @@ -0,0 +1,29 @@ + ['required', 'string'], + 'password' => ['required', 'string'], + 'test_mode' => ['boolean'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/OpenProvider/Provider.php b/src/OpenProvider/Provider.php new file mode 100644 index 0000000..110516c --- /dev/null +++ b/src/OpenProvider/Provider.php @@ -0,0 +1,982 @@ +configuration = $configuration; + $this->baseUrl = $configuration->test_mode + ? 'https://api.cte.openprovider.eu/v1beta/' + : 'https://api.openprovider.eu/v1beta/'; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('OpenProvider') + ->setDescription('Register, transfer, renew and manage domains'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + $sld = Utils::normalizeSld($params->sld); + $domains = array_map(fn ($tld) => ['name' => $sld, 'extension' => Utils::normalizeTld($tld)], $params->tlds); + $response = $this->_callApi([ + 'domains' => $domains, + // 'with_price' => true, + ], 'domains/check', 'POST'); + + $dacDomains = []; + + foreach ($response['data']['results'] as $domainResult) { + $dacDomains[] = DacDomain::create() + ->setDomain($domainResult['domain']) + ->setTld(Utils::getTld($domainResult['domain'])) + ->setCanRegister($domainResult['status'] === 'free') + ->setCanTransfer($domainResult['status'] === 'active') + ->setIsPremium(isset($domainResult['premium'])) + ->setDescription($domainResult['reason'] ?? sprintf('Domain is %s', $domainResult['status'] ?? 'n/a')); + } + + return DacResult::create([ + 'domains' => $dacDomains, + ]); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $data = []; + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + $domainName = Utils::getDomain($sld, $tld); + $data['name_servers'] = []; + + $data['domain'] = [ + 'extension' => Utils::normalizeTld($tld), + 'name' => Utils::normalizeSld($sld), + ]; + + $checkDomain = $this->_checkDomains( + [ + 'domains' => [ + [ + 'tld' => Utils::normalizeTld($tld), + 'sld' => Utils::normalizeSld($sld), + ] + ] + ] + ); + + if (!$checkDomain[0]['available']) { + throw $this->errorResult('Domain is not available to register', ['check' => $checkDomain[0]]); + } + + $data['owner_handle'] = $this->_handleCustomer(Arr::get($params, 'registrant'), 'registrant'); + $data['billing_handle'] = $this->_handleCustomer(Arr::get($params, 'billing'), 'billing'); + $data['admin_handle'] = $this->_handleCustomer(Arr::get($params, 'admin'), 'admin'); + $data['tech_handle'] = $this->_handleCustomer(Arr::get($params, 'tech'), 'tech'); + + $data['period'] = Arr::get($params, 'renew_years', 1); + $data['unit'] = 'y'; + $data['autorenew'] = 'off'; + $data['is_private_whois_enabled'] = Utils::tldSupportsWhoisPrivacy($tld); + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $data['name_servers'][] = [ + 'name' => Arr::get($params, 'nameservers.ns' . $i . '.host'), + 'ip' => Arr::get($params, 'nameservers.ns' . $i . '.ip') + ]; + } + } + + $this->_callApi($data, 'domains', 'POST'); + + return $this->_getDomain($domainName, sprintf('Domain %s registered.', $domainName)); + } + + public function transfer(TransferParams $params): DomainResult + { + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + $domainName = Utils::getDomain($sld, $tld); + + try { + $info = $this->_getDomain($domainName, 'Domain active in registrar account', false); + + /** + * See OpenProvider domain statuses. + * + * @link https://support.openprovider.eu/hc/en-us/articles/216649208-What-is-the-status-of-my-domain-request- + */ + + if (in_array('ACT', $info->statuses)) { + return $info; + } + + if (array_intersect(['REQ', 'SCH', 'PEN'], $info->statuses)) { + throw $this->errorResult(sprintf('Domain transfer in progress since %s', $info->created_at), $info); + } + + // transfer failed - proceed to initiate new transfer + } catch (ProvisionFunctionError $e) { + if (Str::startsWith($e->getMessage(), 'Domain transfer in progress')) { + throw $e; + } + + // domain does not exist - proceed to initiate transfer + } + + $eppCode = Arr::get($params, 'epp_code', "0000"); + $customerId = Arr::get($params, 'admin.id'); + + if (!$customerId) { + $customerId = $this->_handleCustomer(Arr::get($params, 'admin'), 'admin'); + } + + $initiate = $this->initiateTransfer($customerId, $tld, $sld, $eppCode); + + throw $this->errorResult('Domain transfer initiated', [], ['transfer_order' => $initiate]); + } + + public function renew(RenewParams $params): DomainResult + { + $tld = Arr::get($params, 'tld'); + $sld = Arr::get($params, 'sld'); + + $this->_renewDomain($sld, $tld, Arr::get($params, 'renew_years')); + + $domainName = Utils::getDomain($sld, $tld); + return $this->_getDomain($domainName, sprintf('Domain %s has renewed', $domainName)); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domainName = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + return $this->_getDomain($domainName, sprintf('Domain info for %s', $domainName)); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + $paramsApi = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $paramsApi['name_servers'][] = [ + 'name' => Arr::get($params, 'ns' . $i)->host, + 'ip' => Arr::get($params, 'ns' . $i)->ip, + 'seq_nr' => $i - 1, + ]; + } + } + + $this->_callApi( + $paramsApi, + 'domains/' . $domainData['id'], + 'PUT' + ); + + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + + $returnNameservers = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (isset($domainData['domain_servers'][$i])) { + $returnNameservers['ns' . $i] = [ + 'host' => $domainData['domain_servers'][$i]['name'], + 'ip' => $domainData['domain_servers'][$i]['ip'] + ]; + } + } + + return NameserversResult::create($returnNameservers) + ->setMessage('Nameservers are changed'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + $domainData = $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + $paramsApi = []; + + $result = $this->_callApi( + $paramsApi, + 'domains/' . $domainData['id'] . '/authcode' + ); + + $epp = [ + 'epp_code' => $result['data']['auth_code'] + ]; + + return EppCodeResult::create($epp); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + return $this->errorResult('Operation not supported!'); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + // now we always create a new contact + + // $params = $params->toArray(); + // $contact = Arr::get($params, 'contact'); + + // $contactHandle = (string)Arr::get($contact, 'id'); + // if (!$contactHandle) { + // $contactHandle = $this->_callDomain( Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')))['owner_handle']; + // } + + // return $this->_updateCustomer( + // $contactHandle, + // Arr::get($contact, 'email'), + // Arr::get($contact, 'phone'), + // Arr::get($contact, 'name'), + // Arr::get($contact, 'organisation')?? Arr::get($contact, 'name'), + // Arr::get($contact, 'address1'), + // Arr::get($contact, 'postcode'), + // Arr::get($contact, 'city'), + // Arr::get($contact, 'country_code'), + // Arr::get($contact, 'state') + // ); + + $domain = Utils::getDomain($params->sld, $params->tld); + $domainId = $this->_getDomain($domain, '', false)->id; + + $contactHandle = $this->_createCustomer( + $params->contact->email, + $params->contact->phone, + $params->contact->name ?? $params->contact->organisation, + $params->contact->organisation, + $params->contact->address1, + $params->contact->postcode, + $params->contact->city, + $params->contact->country_code, + $params->contact->state + ); + + $this->_callApi([ + 'owner_handle' => $contactHandle, + ], 'domains/' . $domainId, 'PUT'); + + $contact = $this->_getDomain($domain, '', false)->registrant; + + return ContactResult::create($contact)->setMessage('Registrant contat updated'); + } + + public function setLock(LockParams $params): DomainResult + { + $callDomain = $this->_callDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld'))); + + if ($callDomain['is_locked'] == $params->lock) { + return $this->errorResult(sprintf('Domain already %s', $callDomain['is_locked'] ? 'locked' : 'unlocked')); + } + + if (!$callDomain['is_lockable']) { + return $this->errorResult('This domain cannot be locked'); + } + + $lock = Arr::get($params, 'lock'); + $this->_callApi( + [ + 'is_locked' => $lock, + ], + 'domains/' . $callDomain['id'], + 'PUT' + ); + + return $this->_getDomain(Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')), 'Domain lock changed!'); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + throw $this->errorResult('The requested operation not supported', $params); + } + + /** + * @return array + */ + protected function _callDomain(string $domainName) + { + $result = $this->_callApi( + [ + 'full_name' => $domainName, + ], + 'domains' + ); + + if (!isset($result['data']['results'][0])) { + throw new Exception(sprintf('Domain %s has not found!', $domainName), 404); + } + + return $result['data']['results'][0]; + } + + protected function _getDomain( + string $domainName, + string $msg = 'Domain data.', + bool $requireActive = true + ): DomainResult { + $statuses = []; + $ns = []; + $domainDataCall = $this->_callApi( + [ + 'full_name' => $domainName, + ], + 'domains' + ); + + if (!isset($domainDataCall['data']['results'])) { + throw $this->errorResult( + 'Domain name does not exist in registrar account', + ['domain' => $domainName], + ['result_data' => $domainDataCall] + ); + } + + $domainData = $domainDataCall['data']['results'][0]; + + $i = 1; + foreach ($domainData['name_servers'] as $nameServer) { + $ns["ns$i"] = [ + 'host' => $nameServer['name'], + 'ip' => null + ]; + $i++; + } + + if (isset($domainData['status']) && $domainData['status']) { + $statuses = [$domainData['status']]; + } + + switch ($domainData['autorenew']) { + case 'off': + $renew = false; + break; + case 'on': + $renew = true; + break; + default: + $renew = null; + } + + $result = DomainResult::create([ + 'id' => (string)$domainData['id'], + 'domain' => Utils::getDomain(Arr::get($domainData, 'domain.name'), Arr::get($domainData, 'domain.extension')), + 'statuses' => $statuses, + 'locked' => $domainData['is_locked'], + 'renew' => $renew, + 'registrant' => $this->_parseContactInfo($domainData['owner_handle'], 'customers'), + 'billing' => $this->_parseContactInfo($domainData['billing_handle'], 'customers'), + 'admin' => $this->_parseContactInfo($domainData['admin_handle'], 'customers'), + 'tech' => $this->_parseContactInfo($domainData['tech_handle'], 'customers'), + 'ns' => $ns, + 'created_at' => $domainData['creation_date'], + 'updated_at' => $domainData['last_changed'], + 'expires_at' => $domainData['expiration_date'], + ])->setMessage($msg); + + /** + * @link https://support.openprovider.eu/hc/en-us/articles/216649208-What-is-the-status-of-my-domain-request- + */ + if ($requireActive && $domainData['status'] !== 'ACT') { + throw $this->errorResult('Domain status is not active', $result->toArray(), ['domain_data' => $domainData]); + } + + return $result; + } + + protected function _updateCustomer( + ?string $contactHandle, + ?string $email, + ?string $telephone, + ?string $name, + ?string $organization, + ?string $address, + ?string $postcode, + ?string $city, + ?string $country, + ?string $state + ): ContactResult { + if ($country) { + $country = Utils::normalizeCountryCode($country); + } + + if ($telephone) { + $phone = phone($telephone); + $phoneCode = '+' . $phone->getPhoneNumberInstance()->getCountryCode(); + $phoneArea = substr($phone->getPhoneNumberInstance()->getNationalNumber(), 0, 3); + $phone = substr($phone->getPhoneNumberInstance()->getNationalNumber(), 3); + } else { + $phoneCode = ''; + $phone = ''; + $phoneArea = ''; + } + + if ($postcode) { + $postcode = $this->normalizePostCode($postcode, $country); + } + + $params = [ + 'email' => $email, + 'address' => [ + 'city' => $city, + 'country' => Utils::normalizeCountryCode($country), + 'number' => '', + 'state' => $state ?? Utils::normalizeCountryCode($country), + 'street' => $address, + 'suffix' => '', + 'zipcode' => $postcode, + ], + 'phone' => [ + 'area_code' => $phoneArea, + 'country_code' => $phoneCode, + 'subscriber_number' => $phone, + ], + ]; + + $this->_callApi($params, 'customers/' . $contactHandle, 'PUT'); + + return ContactResult::create($this->_parseContactInfo($contactHandle, 'customers')); + } + + protected function formatDate(?string $date): ?string + { + if (!isset($date)) { + return $date; + } + return Carbon::parse($date)->toDateTimeString(); + } + + /** + * @param string $sld + * @param string $tld + * @param int $renewYears + * @return void + * @throws Exception + */ + protected function _renewDomain(string $sld, string $tld, int $renewYears): void + { + $domainName = Utils::getDomain($sld, $tld); + $domain = $this->_getDomain($domainName, 'The expire date is extended.'); + + $this->_callApi( + [ + 'id' => $domain->id, + 'period' => $renewYears + ], + 'domains/' . $domain->id . '/renew', + 'POST' + ); + } + + /** + * Normalize a given contact address post code to satisfy OpenProvider + * requirements. If a GB postcode is given, this method will ensure a space + * is inserted in the correct place. + * + * @param string $postCode Postal code e.g., SW152QT + * @param string $countryCode 2-letter iso code e.g., GB + * + * @return string Post code e.g., SW15 2QT + */ + protected function normalizePostCode(?string $postCode, ?string $countryCode = 'GB'): ?string + { + if (!isset($postCode) || !isset($countryCode) || $this->normalizeCountryCode($countryCode) !== 'GB') { + return $postCode; + } + + return preg_replace( + '/^([a-z]{1,2}[0-9][a-z0-9]?) ?([0-9][a-z]{2})$/i', + '${1} ${2}', + $postCode + ); + } + + protected function normalizeCountryCode(string $countryCode): string + { + return Utils::normalizeCountryCode($countryCode); + } + + private function _exceptionHandler(\Throwable $exception, array $params): void + { + $debug = []; + + if ($exception instanceof ProvisionFunctionError) { + throw $exception->withDebug(array_merge(['params' => $params], $exception->getDebug())); + } + + switch ($exception->getCode()) { + case 2001: + $this->errorResult('Invalid data for making the operation!', $params, $debug, $exception); + break; + default: + $this->errorResult($exception->getMessage(), $params, $debug, $exception); + } + } + + /** + * @param array $data + * @param string $path + * @param string $method + * @return mixed + * @throws Exception + */ + protected function _callApi(array $params, string $path, string $method = 'GET', bool $withToken = true) + { + $url = $this->baseUrl; + $url .= $path ; + $paramKey = 'json'; + + if ($method == 'GET') { + $paramKey = 'query'; + } + + $client = new Client(['handler' => $this->getGuzzleHandlerStack(!!$this->configuration->debug), + ]); + + $headers = []; + + if ($withToken) { + $headers = ['Authorization' => 'Bearer ' . $this->_getToken()]; + } + + $response = $client->request( + $method, + $url, + [ + $paramKey => $params, + 'http_errors' => false, + 'headers' => $headers + ] + ); + + $responseData = json_decode($response->getBody()->__toString(), true); + + if (!isset($responseData['code']) || $responseData['code'] != 0) { + throw $this->_handleApiErrorResponse($response, $responseData); + } + + return $responseData; + } + + /** + * @throws ProvisionFunctionError + * + * @return no-return + */ + protected function _handleApiErrorResponse(Response $response, $responseData): void + { + $errorData = [ + 'http_code' => $response->getStatusCode() + ]; + + if (!isset($responseData['code'])) { + throw $this->errorResult('Unexpected provider response', $errorData, [ + 'response_body' => $response->getBody()->__toString(), + ]); + } + + $message = 'Provider Error: '; + + /** + * Specify a more specific/helpful error message based on the error code. + * + * @link https://support.openprovider.eu/hc/en-us/articles/216644928-API-Error-Codes + */ + switch ($responseData['code']) { + case 399: + $message .= 'An unknown domain error has occurred'; + break; + case 10005: + $message .= 'This IP has not been whitelisted'; + break; + default: + $message .= ($responseData['desc'] ?? 'Unknown error'); + } + + throw $this->errorResult($message, $errorData, [ + 'response_data' => $responseData, + ]); + } + + protected function _getToken(): string + { + if (isset($this->token)) { + return $this->token; + } + + $loginResult = $this->_callApi( + [ + 'username' => $this->configuration['username'], + 'password' => $this->configuration['password'] + ], + 'auth/login', + 'POST', + false + ); + + return $this->token = $loginResult['data']['token']; + } + + protected function _handleCustomer(DataSet $params, string $role = '') + { + if (Arr::has($params, 'id')) { + $customerId = Arr::get($params, 'id'); + } else { + $customerId = $this->_createCustomer( + Arr::get($params, 'register.email'), + Arr::get($params, 'register.phone'), + Arr::get($params, 'register.name') ?? Arr::get($params, 'register.organisation'), + Arr::get($params, 'register.organisation') ?? Arr::get($params, 'register.name'), + Arr::get($params, 'register.address1'), + Arr::get($params, 'register.postcode'), + Arr::get($params, 'register.city'), + Arr::get($params, 'register.country_code'), + Arr::get($params, 'register.state') + ); + } + return $customerId; + } + + protected function _createCustomer( + string $email, + string $telephone, + string $name, + string $organization = null, + string $address, + string $postcode, + string $city, + string $countryName, + ?string $state + ): string { + // always create a new customer + + // $customer = $this->_getCustomer($email); + + // if ($customer != null) { + // return $customer['handle']; + // } + + if ($telephone) { + $telephone = Utils::internationalPhoneToEpp($telephone); + $phone = phone($telephone); + $phoneCode = '+' . (string)$phone->getPhoneNumberInstance()->getCountryCode(); + $phoneArea = substr($phone->getPhoneNumberInstance()->getNationalNumber(), 0, 3); + $phone = substr($phone->getPhoneNumberInstance()->getNationalNumber(), 3); + } else { + $phoneCode = ''; + $phone = ''; + $phoneArea = ''; + } + $nameParts = explode(' ', $name); + $lastName = $name; + if (count($nameParts) > 1) { + $lastName = $nameParts[count($nameParts) - 1]; + } + + if (empty(trim((string)$organization))) { + $organization = null; // convert empty string / whitespace to null + } + + $data = [ + 'name' => [ + 'first_name' => $nameParts[0], + 'full_name' => $name, + 'initials' => '', + 'last_name' => $lastName, + 'prefix' => '', + ], + 'phone' => [ + 'area_code' => $phoneArea, + 'country_code' => $phoneCode, + 'subscriber_number' => $phone, + ], + 'email' => $email, + 'company_name' => $organization, + 'address' => [ + 'city' => $city, + 'country' => Utils::normalizeCountryCode($countryName), + 'number' => '', + 'state' => $state, + 'street' => $address, + 'suffix' => '', + 'zipcode' => $postcode, + ], + ]; + + return $this->_callApi($data, 'customers', 'POST')['data']['handle']; + } + + private function initiateTransfer(string $customerId, string $tld, string $sld, $eppCode): array + { + $params = []; + $params['domain'] = [ + 'extension' => Utils::normalizeTld($tld), + 'name' => Utils::normalizeSld($sld), + ]; + $params['auth_code'] = $eppCode; + $params['owner_handle'] = $customerId; + $params['tech_handle'] = $customerId; + $params['admin_handle'] = $customerId; + $params['billing_handle'] = $customerId; + $params['autorenew'] = 'off'; + $params['unit'] = 'y'; + $params['name_servers'] = array_map(function (string $hostname) { + return [ + 'name' => $hostname, + ]; + }, Utils::lookupNameservers(Utils::getDomain($sld, $tld), false) ?? self::DEFAULT_NAMESERVERS); + + $transferOrder = $this->_callApi($params, 'domains/transfer', 'POST'); + + if (!is_array($transferOrder)) { + $transferOrder = json_decode($transferOrder, true); + } + + return $transferOrder; + } + + private function _parseContactInfo(string $handle, string $path) + { + if (!$handle) { + return []; + } + + $uri = $path . '/' . $handle; + + if (isset($this->contacts[$uri])) { + return $this->contacts[$uri]; + } + + $contactApi = $this->_callApi( + [], + $uri, + 'GET' + ); + + if ($contactApi['code'] !== 0) { + return []; + } + + $contact = $contactApi['data']; + + $countryCode = ($contact['address']['country'] ?? ''); + return $this->contacts[$uri] = [ + 'id' => $handle, + 'name' => ($contact['name']['full_name'] ?? ''), + 'email' => $contact['email'], + 'phone' => ($contact['phone']['country_code'] ?? '') . ($contact['phone']['area_code'] ?? '') . ($contact['phone']['subscriber_number'] ?? ''), + 'organisation' => ($contact['company_name'] ?? ''), + 'address1' => ($contact['address']['street'] ?? '') . ' ' . ($contact['address']['number'] ?? ''), + 'city' => ($contact['address']['city'] ?? ''), + 'postcode' => ($contact['address']['zipcode'] ?? ''), + 'country_code' => $countryCode, + 'status' => (!$contact['is_deleted']) ? 'Active' : 'Deleted', + 'state' => ($contact['address']['state'] ?? ''), + ]; + } + + private function _getCustomer(string $email): ?array + { + $customerApi = $this->_callApi( + ['email_pattern' => $email], + 'customers', + 'GET' + ); + + return $customerApi['data']['results'][0] ?? null; + } + + /** + * @param array $params + * @return array + * @throws Exception + */ + private function _checkDomains(array $params): array + { + $domains = Arr::get($params, 'domains'); + $paramsApi = []; + $result = []; + + try { + foreach ($domains as $domain) { + $paramsApi['domains'][] = [ + 'extension' => Utils::normalizeTld(Arr::get($domain, 'tld')), + 'name' => Utils::normalizeSld(Arr::get($domain, 'sld')), + ]; + } + + $response = $this->_callApi($paramsApi, 'domains/check', 'POST'); + foreach ($response['data']['results'] as $resp) { + $result[] = [ + 'sld' => Arr::get($domain, 'sld'), + 'tld' => Arr::get($domain, 'tld'), + 'domain' => $domain, + 'available' => ($resp['status'] == 'free') ? true : false, + 'reason' => $resp['reason'] ?? null + ]; + } + } catch (\Exception $e) { + $this->_exceptionHandler($e, $params); + } + + return $result; + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Polling not yet supported'); + + $notifications = []; + $offset = 0; + $limit = 10; + $count = 1; + + /** + * Start a timer because there may be 1000s of irrelevant messages and we should try and avoid a timeout. + */ + $timeLimit = 60; // 60 seconds + $startTime = time(); + + $since = $params->after_date ? Carbon::parse($params->after_date) : null; + while (true) { + $response = $this->_callApi([ + 'limit' => $limit, + 'offset' => $offset, +// 'with_api_history' => 'true', +// 'with_history' => 'true', +// 'with_additional_data' => 'true', + ], 'domains', 'GET'); + + $offset += $limit; + + + if (!isset($response['data']['results']) || $response['data']['total'] == 0) { + break; + } + + for ($i = 0; $i < $response['data']['total']; $i++) { + $domain = $response['data']['results'][$i]['domain']['name'] . '.' . $response['data']['results'][$i]['domain']['extension']; + + $creationDate = Carbon::createFromTimeString($response['data']['results'][$i]['creation_date']); + + if ($since != null && $since->gt(Carbon::parse($creationDate))) { + continue; + } + + if ($count > $params->limit) { + break 2; + } + + $count++; + + if ((time() - $startTime) >= $timeLimit) { + break 2; + } + + $status = $this->mapType($response['data']['results'][$i]['status']); + + if ($status == null) { + continue; + } + $notifications[] = DomainNotification::create() + ->setId('N/A') + ->setType($status) + ->setMessage($response['data']['results'][$i]['status']) + ->setDomains([$domain]) + ->setCreatedAt($creationDate) + ->setExtra([]); + } + } + + return new PollResult([ + 'count_remaining' => (count($notifications) - $params->limit < 0) ? 0 : count($notifications) - $params->limit, + 'notifications' => $notifications, + ]); + } + + private function mapType(string $type): ?string + { + switch ($type) { + case 'PRE': + return DomainNotification::TYPE_TRANSFER_OUT; + case 'DEL': + return DomainNotification::TYPE_DELETED; + } + return null; + } +} diff --git a/src/OpenSRS/Data/OpenSrsConfiguration.php b/src/OpenSRS/Data/OpenSrsConfiguration.php new file mode 100644 index 0000000..94dbe3a --- /dev/null +++ b/src/OpenSRS/Data/OpenSrsConfiguration.php @@ -0,0 +1,27 @@ + ['required', 'string', 'min:3'], + 'key' => ['required', 'string', 'min:6'], + 'sandbox' => ['boolean'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/OpenSRS/Helper/OpenSrsApi.php b/src/OpenSRS/Helper/OpenSrsApi.php new file mode 100644 index 0000000..b0f598a --- /dev/null +++ b/src/OpenSRS/Helper/OpenSrsApi.php @@ -0,0 +1,472 @@ +client = $client; + $this->configuration = $configuration; + } + + /** + * @param string|null $name + * @return array + */ + public static function getNameParts(?string $name): array + { + $nameParts = explode(" ", $name); + $firstName = array_shift($nameParts); + $lastName = implode(" ", $nameParts); + + // OpenSRS doesn't tolerate empty `last_name` param, so... here's a workaround + if (empty($lastName)) { + $lastName = $firstName; + } + + return compact('firstName', 'lastName'); + } + + /** + * @param string $type + * @param array $rawContactData + */ + private static function validateContactType(string $type, array $rawContactData): void + { + if (!in_array(strtolower($type), self::ALLOWED_CONTACT_TYPES) || !isset($rawContactData[$type])) { + throw new RuntimeException(sprintf('Invalid contact type %s used!', $type)); + } + } + + /** + * @param array $nameServers + * @return array + */ + public static function parseNameservers(array $nameServers): array + { + $result = []; + + if (count($nameServers) > 0) { + foreach ($nameServers as $i => $ns) { + $result['ns' . ($i + 1)] = [ + 'host' => $ns['name'], + 'ip' => $ns['ipaddress'] // No IP address available + ]; + } + } + + return $result; + } + + /** + * @param array $rawContactData + * @param string $type Contact Type (owner, tech, admin, billing) + * @return DomainContactInfo + */ + public static function parseContact(array $rawContactData, string $type): DomainContactInfo + { + // Check if our contact type is valid + self::validateContactType($type, $rawContactData); + + $rawContactData = $rawContactData[$type]; + + return DomainContactInfo::create([ + 'contact_id' => $type, + 'name' => sprintf('%s %s', (string) $rawContactData['first_name'], (string) $rawContactData['last_name']), + 'email' => (string) $rawContactData['email'], + 'phone' => (string) $rawContactData['phone'], + 'organisation' => (string) $rawContactData['org_name'], + 'address1' => (string) $rawContactData['address1'], + 'city' => (string) $rawContactData['city'], + 'state' => $rawContactData['state'], + 'postcode' => (string) $rawContactData['postal_code'], + 'country_code' => (string) $rawContactData['country'], + 'type' => null, + ]); + } + + /** + * Get the correct endpoint, depending on the environment + * + * @param bool $sandbox + * @return string + */ + private static function getApiEndpoint(bool $sandbox = false): string + { + return $sandbox ? 'https://horizon.opensrs.net:55443' : 'https://rr-n1-tor.opensrs.net:55443'; + } + + /** + * Send request and return the response. + * + * @param array $params + * @return array + * + * @throws ProvisionFunctionError + */ + public function makeRequest(array $params): array + { + // Request Template + $xmlDataBlock = self::array2xml($params); + + $xml = '' . self::CRLF . + '' . self::CRLF . + '' . self::CRLF . + self::XML_INDENT . '
' . self::CRLF . + self::XML_INDENT . self::XML_INDENT . '' . self::OPS_VERSION . '' . self::CRLF . + self::XML_INDENT . '
' . self::CRLF . + self::XML_INDENT . '' . self::CRLF . + $xmlDataBlock . self::CRLF . + self::XML_INDENT . '' . self::CRLF . + '
'; + + $response = $this->client->request('POST', self::getApiEndpoint(!!$this->configuration->debug), [ + 'body' => $xml, + 'headers' => [ + 'User-Agent' => 'Upmind/ProvisionProviders/DomainNames/OpenSRS', + 'Content-Type' => 'text/xml', + 'X-Username' => $this->configuration->username, + 'X-Signature' => md5(md5($xml . $this->configuration->key) . $this->configuration->key), + 'Content-Length' => strlen($xml) + ], + ]); + + $result = $response->getBody()->getContents(); + + // Init cUrl + if (empty($result)) { + $response->getBody()->close(); + + // Something bad happened... + throw new RuntimeException('Problem while sending OpenSRS request.'); + } + + $response->getBody()->close(); + + $responseData = self::parseResponseData($result); + + // Return the result + return $responseData; + } + + /** + * Taken from https://github.com/OpenSRS/osrs-toolkit-php + * + * @param array $data + * @return string + */ + private static function array2xml(array $data): string + { + return str_repeat(self::XML_INDENT, 2) . '' . self::convertData($data, 3) . self::CRLF . str_repeat(self::XML_INDENT, 2) . ''; + } + + /** + * Taken from https://github.com/OpenSRS/osrs-toolkit-php + * Minor modifications done + * + * @param $array + * @param int $indent + * @return string + */ + private static function convertData(&$array, $indent = 0): string + { + $string = ''; + $spacer = str_repeat(self::XML_INDENT, $indent); + + if (is_array($array)) { + if (self::isAssoc($array)) { # HASH REFERENCE + $string .= self::CRLF . $spacer . ''; + $end = ''; + } else { # ARRAY REFERENCE + $string .= self::CRLF . $spacer . ''; + $end = ''; + } + + foreach ($array as $k => $v) { + ++$indent; + /* don't encode some types of stuff */ + if ((gettype($v) == 'resource') || (gettype($v) == 'user function') || (gettype($v) == 'unknown type')) { + continue; + } + + $string .= self::XML_INDENT . $spacer . ''; + } else { + $string .= self::quoteXmlChars($v) . ''; + } + + --$indent; + } + $string .= self::XML_INDENT . $spacer . $end; + } else { + $string .= self::XML_INDENT . $spacer . '' . self::quoteXmlChars($array) . ''; + } + + return $string; + } + + /** + * Taken from https://github.com/OpenSRS/osrs-toolkit-php + * + * Quotes special XML characters. + * + * @param string string to quote + * @return string quoted string + */ + private static function quoteXmlChars($string): string + { + $search = ['&', '<', '>', "'", '"']; + $replace = ['&', '<', '>', ''', '"']; + $string = str_replace($search, $replace, $string); + $string = utf8_encode($string); + + return $string; + } + + /** + * Taken from https://github.com/OpenSRS/osrs-toolkit-php + * + * Determines if an array is associative or not, since PHP + * doesn't really distinguish between the two, but Perl/OPS does. + * + * @param array array to check + * @return bool true if the array is associative + */ + private static function isAssoc(array &$array): bool + { + /* + * Empty array should default to associative + * SRS was having issues with empty attribute arrays + */ + if (empty($array)) { + return true; + } + + if (is_array($array)) { + foreach ($array as $k => $v) { + if (!is_int($k)) { + return true; + } + } + } + + return false; + } + + /** + * Parse and process the XML Response + * + * @param string $result + * @return array + * + * @throws ProvisionFunctionError + */ + private static function parseResponseData(string $result): array + { + $data = self::xml2php($result); + + // Check the XML for errors + if (!isset($data['is_success'])) { + throw static::errorResult('Registrar API Response Error', ['response' => $result, 'data' => $data]); + } + + if ((int)$data['is_success'] === 0 && !in_array($data['response_code'], [200, 212])) { + throw static::errorResult('Registrar API Error: ' . $data['response_text'], $data); + } + + return $data; + } + + /** + * Throws a ProvisionFunctionError to interrupt execution and generate an + * error result. + * + * @param string $message Error result message + * @param array $data Error data + * @param array $debug Error debug + * @param Throwable|null $previous Encountered exception + * + * @throws ProvisionFunctionError + * + * @return no-return + */ + public static function errorResult($message, $data = [], $debug = [], ?Throwable $previous = null): void + { + throw (new ProvisionFunctionError($message, 0, $previous)) + ->withData($data) + ->withDebug($debug); + } + + /** + * Method is taken from https://github.com/OpenSRS/osrs-toolkit-php + * Minor modifications done + * + * @param string $msg + * @return array|null + */ + public static function xml2php(string $msg): ?array + { + $data = null; + + $xp = xml_parser_create(); + xml_parser_set_option($xp, XML_OPTION_CASE_FOLDING, false); + xml_parser_set_option($xp, XML_OPTION_SKIP_WHITE, true); + xml_parser_set_option($xp, XML_OPTION_TARGET_ENCODING, 'ISO-8859-1'); + + if (!xml_parse_into_struct($xp, $msg, $vals, $index)) { + $error = sprintf( + 'XML error: %s at line %d', + xml_error_string(xml_get_error_code($xp)), + xml_get_current_line_number($xp) + ); + xml_parser_free($xp); + } elseif (empty($vals)) { + $error = 'Unable to parse XML values'; + } + + if (isset($error)) { + static::errorResult('Unexpected Registrar API Error', ['error' => $error, 'response' => $msg]); + } + + xml_parser_free($xp); + $temp = $depth = []; + + foreach ($vals as $value) { + switch ($value['tag']) { + case 'OPS_envelope': + case 'header': + case 'body': + case 'data_block': + break; + case 'version': + case 'msg_id': + case 'msg_type': + $key = '_OPS_' . $value['tag']; + $temp[$key] = $value['value']; + break; + case 'item': + // Not every Item has attributes + if (isset($value['attributes'])) { + $key = $value['attributes']['key']; + } else { + $key = ''; + } + + switch ($value['type']) { + case 'open': + array_push($depth, $key); + break; + case 'complete': + array_push($depth, $key); + $p = implode('::', $depth); + + // enn_change - make sure that $value['value'] is defined + if (isset($value['value'])) { + $temp[$p] = $value['value']; + } else { + $temp[$p] = ''; + } + + array_pop($depth); + break; + case 'close': + array_pop($depth); + break; + } + break; + case 'dt_assoc': + case 'dt_array': + break; + } + } + + foreach ($temp as $key => $value) { + $levels = explode('::', $key); + $num_levels = count($levels); + + if ($num_levels == 1) { + $data[$levels[0]] = $value; + } else { + $pointer = &$data; + for ($i = 0; $i < $num_levels; ++$i) { + if (!isset($pointer[$levels[$i]])) { + $pointer[$levels[$i]] = []; + } + $pointer = &$pointer[$levels[$i]]; + } + $pointer = $value; + } + } + + return $data; + } + + /** + * @param array $xmlErrors + * @return string + */ + private static function formatOpenSrsErrorMessage(array $xmlErrors): string + { + return sprintf('OpenSRS API Error: %s', implode(', ', $xmlErrors)); + } +} diff --git a/src/OpenSRS/Provider.php b/src/OpenSRS/Provider.php new file mode 100644 index 0000000..b462a29 --- /dev/null +++ b/src/OpenSRS/Provider.php @@ -0,0 +1,817 @@ +configuration = $configuration; + } + + /** + * @return AboutData + */ + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('OpenSRS') + ->setDescription('Register, transfer, renew and manage OpenSRS domains') + ->setLogoUrl('https://api.upmind.io/images/logos/provision/opensrs-logo@2x.png'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + throw $this->errorResult('Operation not supported'); + + // Get Domains + $domains = []; + + $max = 30; + $start = 0; + + foreach (Arr::get($params, 'domains') as $domain) { + $domains[] = Utils::getDomain($domain['sld'], $domain['tld']); + + $start++; + + // Allow up to 30 domains in one check + if ($start == $max) { + break; + } + } + + try { + $domainsCheck = []; + + foreach ($domains as $domain) { + $lookupRaw = $this->api()->makeRequest([ + 'action' => 'LOOKUP', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + ] + ]); + + $domainsCheck[] = [ + 'domain' => $domain, + 'available' => $lookupRaw['attributes']['status'] == 'taken' ? false : true, + 'reason' => '' + ]; + } + + return $this->okResult("Domain Check Results", $domainsCheck); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + /** + * @param RegisterDomainParams $params + * @return DomainResult + */ + public function register(RegisterDomainParams $params): DomainResult + { + // Get Params + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + $domain = Utils::getDomain($sld, $tld); + + try { + if (!Arr::has($params, 'registrant.register')) { + return $this->errorResult('Registrant contact data is required!'); + } + + if (!Arr::has($params, 'tech.register')) { + return $this->errorResult('Tech contact data is required!'); + } + + if (!Arr::has($params, 'admin.register')) { + return $this->errorResult('Admin contact data is required!'); + } + + if (!Arr::has($params, 'billing.register')) { + return $this->errorResult('Billing contact data is required!'); + } + + // Register the domain with the registrant contact data + $nameServers = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $nameServers[] = [ + 'name' => Arr::get($params, 'nameservers.ns' . $i)->host, + 'sortorder' => $i + ]; + } + } + + $contactData = [ + OpenSrsApi::CONTACT_TYPE_REGISTRANT => $params->registrant->register, + OpenSrsApi::CONTACT_TYPE_TECH => $params->tech->register, + OpenSrsApi::CONTACT_TYPE_ADMIN => $params->admin->register, + OpenSrsApi::CONTACT_TYPE_BILLING => $params->billing->register + ]; + + $contacts = []; + + foreach ($contactData as $type => $contactParams) { + $nameParts = OpenSrsApi::getNameParts($contactParams->name ?? $contactParams->organisation); + + $contacts[$type] = array_filter([ + 'country' => Utils::normalizeCountryCode($contactParams->country_code), + 'org_name' => $contactParams->organisation ?? $contactParams->name, + 'phone' => Utils::internationalPhoneToEpp($contactParams->phone), + 'postal_code' => $contactParams->postcode, + 'city' => $contactParams->city, + 'email' => $contactParams->email, + 'address1' => $contactParams->address1, + 'first_name' => $nameParts['firstName'], + 'last_name' => $nameParts['lastName'], + 'state' => Utils::stateNameToCode($contactParams->country_code, $contactParams->state), + ]); + } + + $result = $this->api()->makeRequest([ + 'action' => 'SW_REGISTER', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + //'f_whois_privacy' => '' // TODO - privacy + 'domain' => $domain, + 'reg_username' => bin2hex(random_bytes(6)), + 'reg_password' => bin2hex(random_bytes(6)), + 'handle' => 'process', + 'period' => Arr::get($params, 'renew_years', 1), + 'reg_type' => 'new', + 'custom_nameservers' => 1, + 'contact_set' => $contacts, + 'custom_tech_contact' => 0, + 'nameserver_list' => $nameServers + ] + ]); + + if (!empty($result['attributes']['forced_pending'])) { + // domain could not be registered at this time + return $this->errorResult('Domain registration pending approval', $result); + } + + // Return newly fetched data for the domain + return $this->_getInfo($sld, $tld, sprintf('Domain %s was registered successfully!', $domain)); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param TransferParams $params + * @return DomainResult + */ + public function transfer(TransferParams $params): DomainResult + { + // Get the domain name + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + $domain = Utils::getDomain($sld, $tld); + + $checkPendingResult = $this->api()->makeRequest([ + 'action' => 'get_transfers_in', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + ], + ]); + + foreach ($checkPendingResult['attributes']['transfers'] ?? [] as $transfer) { + if (!empty($transfer['completed_date'])) { + continue; // if transfer is completed, great + } + + $initiated = Carbon::createFromTimestamp($transfer['order_date_epoch']) + ->diffForHumans([ + 'parts' => 1, + 'options' => CarbonInterface::ROUND, + ]); // X days ago + + switch ($transfer['status']) { + case 'completed': + case 'cancelled': + continue 2; + case 'pending_owner': + return $this->errorResult( + sprintf('Transfer initiated %s is pending domain owner approval', $initiated), + ['transfer' => $transfer] + ); + case 'pending_registry': + return $this->errorResult( + sprintf('Transfer initiated %s is pending registry approval', $initiated), + ['transfer' => $transfer] + ); + default: + return $this->errorResult( + sprintf('Transfer initiated %s is in progress', $initiated), + ['transfer' => $transfer] + ); + } + } + + try { + return $this->_getInfo($sld, $tld, 'Domain active in registrar account!'); + } catch (\Throwable $e) { + // ignore error and attempt to initiate transfer + } + + $period = Arr::get($params, 'renew_years', 1); + $eppCode = Arr::get($params, 'epp_code', ""); + + $contacts = []; + + if (!Arr::has($params, 'admin.register')) { + return $this->errorResult('Admin contact data is required!'); + } + + $contactData = [ + OpenSrsApi::CONTACT_TYPE_REGISTRANT => Arr::get($params, 'admin.register'), + OpenSrsApi::CONTACT_TYPE_TECH => Arr::get($params, 'admin.register'), + OpenSrsApi::CONTACT_TYPE_ADMIN => Arr::get($params, 'admin.register'), + OpenSrsApi::CONTACT_TYPE_BILLING => Arr::get($params, 'admin.register') + ]; + + foreach ($contactData as $type => $contactParams) { + /** @var ContactParams $contactParams */ + $nameParts = OpenSrsApi::getNameParts($contactParams->name ?? $contactParams->organisation); + + $contacts[$type] = [ + 'country' => Utils::normalizeCountryCode($contactParams->country_code), + 'state' => Utils::stateNameToCode($contactParams->country_code, $contactParams->state), + 'org_name' => $contactParams->organisation, + 'phone' => Utils::internationalPhoneToEpp($contactParams->phone), + 'postal_code' => $contactParams->postcode, + 'city' => $contactParams->city, + 'email' => $contactParams->email, + 'address1' => $contactParams->address1, + 'first_name' => $nameParts['firstName'], + 'last_name' => $nameParts['lastName'], + ]; + } + + try { + $username = substr(str_replace(['.', '-'], '', $sld), 0, 16) + . substr(str_replace(['.', '-'], '', $tld), 0, 4); + + $this->api()->makeRequest([ + 'action' => 'SW_REGISTER', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + 'reg_username' => $username, + 'reg_password' => bin2hex(random_bytes(6)), + 'auth_info' => $eppCode, + 'change_contact' => 0, + 'handle' => 'process', + 'period' => $period, + 'reg_type' => 'transfer', + 'custom_tech_contact' => 0, + 'custom_nameservers' => 0, + 'link_domains' => 0, + 'contact_set' => $contacts + ] + ]); + + return $this->errorResult('Domain transfer initiated'); + + /*return DomainResult::create([ + 'id' => $domain, + 'domain' => $domain, + 'statuses' => [], // nothing relevant here right now + 'registrant' => DomainContactInfo::create($contactData[OpenSrsApi::CONTACT_TYPE_REGISTRANT]), + 'ns' => [], + 'created_at' => Carbon::today()->toDateString(), + 'updated_at' => Carbon::today()->toDateString(), + 'expires_at' => Carbon::today()->toDateString() + ])->setMessage('Domain transfer has been initiated!');*/ + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param RenewParams $params + * @return DomainResult + */ + public function renew(RenewParams $params): DomainResult + { + // Get the domain name + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + $domain = Utils::getDomain($sld, $tld); + $period = Arr::get($params, 'renew_years', 1); + + try { + // We need to know the current expiration year + $domainRaw = $this->api()->makeRequest([ + 'action' => 'GET', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + 'type' => 'all_info', + 'clean_ca_subset' => 1, + //'active_contacts_only' => 1 + ] + ]); + + $expiryDate = Carbon::parse($domainRaw['attributes']['expiredate']); + + // Set renewal data + $domainRaw = $this->api()->makeRequest([ + 'action' => 'RENEW', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'handle' => 'process', + 'period' => $period, + 'currentexpirationyear' => $expiryDate->year + //'premium_price_to_verify' => 'PREMIUM-DOMAIN-PRICE' + ] + ]); + + // Get Domain Info (again) + return $this->_getInfo( + $sld, + $tld, + sprintf('Renewal for %s domain was successful!', $domain) + ); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param DomainInfoParams $params + * @return DomainResult + */ + public function getInfo(DomainInfoParams $params): DomainResult + { + try { + return $this->_getInfo(Arr::get($params, 'sld'), Arr::get($params, 'tld'), 'Domain data obtained'); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param string $sld + * @param string $tld + * @param string $message + * @return DomainResult + */ + private function _getInfo(string $sld, string $tld, string $message): DomainResult + { + try { + $domainRaw = $this->api()->makeRequest([ + 'action' => 'GET', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'type' => 'all_info', + 'clean_ca_subset' => 1, + // 'active_contacts_only' => 1 + ] + ]); + + $statusRaw = $this->api()->makeRequest([ + 'action' => 'GET', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'type' => 'status', + // 'clean_ca_subset' => 1, + // 'active_contacts_only' => 1 + ] + ]); + } catch (ProvisionFunctionError $e) { + if (Str::contains($e->getMessage(), 'Authentication Error')) { + // this actually means domain not found + $this->errorResult('Domain name not found', $e->getData(), $e->getDebug(), $e); + } + + throw $e; + } + + $domainInfo = [ + 'id' => (string) Utils::getDomain($sld, $tld), + 'domain' => (string) Utils::getDomain($sld, $tld), + 'statuses' => array_map(function ($status) { + return $status === '' ? 'n/a' : (string)$status; + }, $statusRaw['attributes']), + 'registrant' => OpenSrsApi::parseContact($domainRaw['attributes']['contact_set'], OpenSrsApi::CONTACT_TYPE_REGISTRANT), + 'ns' => OpenSrsApi::parseNameServers($domainRaw['attributes']['nameserver_list']), + 'created_at' => $domainRaw['attributes']['registry_createdate'], + 'updated_at' => $domainRaw['attributes']['registry_updatedate'] ?? $domainRaw['attributes']['registry_createdate'], + 'expires_at' => $domainRaw['attributes']['expiredate'], + 'locked' => boolval($statusRaw['attributes']['lock_state']), + ]; + + return DomainResult::create($domainInfo)->setMessage($message); + } + + /** + * @param UpdateNameserversParams $params + * @return NameserversResult + */ + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + // Get Domain Name and NameServers + $domain = Utils::getDomain(Arr::get($params, 'sld'), Arr::get($params, 'tld')); + + $nameServers = []; + $currentNameServers = []; + $nameServersForResponse = []; + + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $nameServer = Arr::get($params, 'ns' . $i); + $nameServers[] = $nameServer->toArray()['host']; + $nameServersForResponse['ns' . $i] = ['host' => $nameServer->toArray()['host'], 'ip' => null]; + } + } + + try { + // Get current nameservers, which will be removed + $currentNameServersRaw = $this->api()->makeRequest([ + 'action' => 'GET', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + 'type' => 'nameservers' + ] + ]); + + foreach ($currentNameServersRaw['attributes']['nameserver_list'] as $ns) { + $currentNameServers[] = $ns['name']; + } + + // Make sure the new naneservers exist in the registry + foreach ($nameServers as $ns) { + $existsData = $this->api()->makeRequest([ + 'action' => 'REGISTRY_CHECK_NAMESERVER', + 'object' => 'NAMESERVER', + 'protocol' => 'XCP', + 'attributes' => [ + 'tld' => Arr::get($params, 'tld'), + 'fqdn' => $ns + ] + ]); + + if ((int) $existsData['response_code'] == 212) { + // NameServer doesn't exists in the registry so we need to add it. + $this->api()->makeRequest([ + 'action' => 'REGISTRY_ADD_NS', + 'object' => 'NAMESERVER', + 'protocol' => 'XCP', + 'attributes' => [ + 'tld' => Arr::get($params, 'tld'), + 'fqdn' => $ns, + 'all' => 0 + ] + ]); + } + } + + // Prepare params + $requestParams = [ + 'action' => 'ADVANCED_UPDATE_NAMESERVERS', + 'object' => 'NAMESERVER', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => $domain, + 'op_type' => 'add_remove', + 'add_ns' => $nameServers + ] + ]; + + // Remove old + $toRemove = array_values(array_diff($currentNameServers, $nameServers)); + + if (count($toRemove) > 0) { + $requestParams['attributes']['remove_ns'] = $toRemove; + } + + // Update nameservers + $nameServersRaw = $this->api()->makeRequest($requestParams); + + return NameserversResult::create($nameServersForResponse) + ->setMessage(sprintf('Name servers for %s domain were updated!', $domain)); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * Emails EPP code to the registrant's email address. + * + * @param EppParams $params + * @return EppCodeResult + */ + public function getEppCode(EppParams $params): EppCodeResult + { + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + try { + $domainRaw = $this->api()->makeRequest([ + 'action' => 'GET', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'type' => 'domain_auth_info' + ] + ]); + + return EppCodeResult::create([ + 'epp_code' => $domainRaw['attributes']['domain_auth_info'] + ])->setMessage('EPP/Auth code obtained'); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param UpdateDomainContactParams $params + * @return ContactResult + */ + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + return $this->updateContact($params->sld, $params->tld, $params->contact, OpenSrsApi::CONTACT_TYPE_REGISTRANT); + } + + /** + * @param LockParams $params + * @return ResultData + */ + public function setLock(LockParams $params): DomainResult + { + // Get the domain name + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + $lock = (bool) Arr::get($params, 'lock', false); + + $domain = Utils::getDomain($sld, $tld); + + try { + $domainRaw = $this->api()->makeRequest([ + 'action' => 'MODIFY', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'data' => 'status', + 'lock_state' => (int) $lock + ] + ]); + + return $this->_getInfo($sld, $tld, sprintf("Lock %s!", $lock ? 'enabled' : 'disabled')); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param AutoRenewParams $params + * @return ResultData + */ + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + // Get the domain name + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + $domain = Utils::getDomain($sld, $tld); + $autoRenew = (bool) $params->auto_renew; + + try { + $domainRaw = $this->api()->makeRequest([ + 'action' => 'MODIFY', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'data' => 'expire_action', + 'auto_renew' => (int) $autoRenew, + 'let_expire' => 0 + ] + ]); + + return $this->_getInfo($sld, $tld, 'Domain auto-renew mode updated'); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param IpsTagParams $params + * @return ResultData + */ + public function updateIpsTag(IpsTagParams $params): ResultData + { + // Get the domain name + $sld = Arr::get($params, 'sld'); + $tld = Arr::get($params, 'tld'); + + $domain = Utils::getDomain($sld, $tld); + $ipsTag = Arr::get($params, 'ips_tag'); + + try { + $domainRaw = $this->api()->makeRequest([ + 'action' => 'MODIFY', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'affect_domains' => 0, + 'change_tag_all' => 0, + 'domain' => Utils::getDomain($sld, $tld), + 'data' => 'change_ips_tag', + 'gaining_registrar_tag' => $ipsTag + ] + ]); + + return $this->okResult(sprintf("IPS tag for domain %s has been changed!", $domain)); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param string $sld + * @param string $tld + * @param ContactParams $params + * @param string $type + * @return ContactResult + */ + private function updateContact(string $sld, string $tld, ContactParams $params, string $type): ContactResult + { + try { + $nameParts = OpenSrsApi::getNameParts($params->name ?? $params->organisation); + + $updateContactRaw = $this->api()->makeRequest([ + 'action' => 'UPDATE_CONTACTS', + 'object' => 'DOMAIN', + 'protocol' => 'XCP', + 'attributes' => [ + 'domain' => Utils::getDomain($sld, $tld), + 'types' => [$type], + 'contact_set' => [ + $type => [ + 'country' => Utils::normalizeCountryCode($params->country_code), + 'state' => Utils::stateNameToCode($params->country_code, $params->state), + 'org_name' => $params->organisation, + 'phone' => Utils::internationalPhoneToEpp($params->phone), + 'postal_code' => $params->postcode, + 'city' => $params->city, + 'email' => $params->email, + 'address1' => $params->address1, + 'first_name' => $nameParts['firstName'], + 'last_name' => $nameParts['lastName'], + ] + ] + ] + ]); + + return ContactResult::create([ + 'contact_id' => strtolower($type), + 'name' => $params->name, + 'email' => $params->email, + 'phone' => $params->phone, + 'organisation' => $params->organisation, + 'address1' => $params->address1, + 'city' => $params->city, + 'postcode' => $params->postcode, + 'country_code' => $params->country_code, + 'state' => Utils::stateNameToCode($params->country_code, $params->state), + ])->setMessage('Contact details updated'); + } catch (\Throwable $e) { + return $this->handleError($e, $params); + } + } + + /** + * @param Throwable $e Encountered error + * @param DataSet|mixed[] $params + * + * @throws ProvisionFunctionError + * + * @return no-return + */ + protected function handleError(Throwable $e, $params): void + { + if ($e instanceof ProvisionFunctionError) { + throw $e; + } + + throw $e; // i dont want to just blindly copy any unknown error message into a the result + } + + protected function api(): OpenSrsApi + { + if (isset($this->apiClient)) { + return $this->apiClient; + } + + $client = new Client([ + 'connect_timeout' => 10, + 'timeout' => 60, + 'verify' => !$this->configuration->sandbox, + 'handler' => $this->getGuzzleHandlerStack(boolval($this->configuration->debug)), + ]); + + return $this->apiClient = new OpenSrsApi($client, $this->configuration); + } +} diff --git a/src/ResellBiz/Provider.php b/src/ResellBiz/Provider.php new file mode 100644 index 0000000..a18ac58 --- /dev/null +++ b/src/ResellBiz/Provider.php @@ -0,0 +1,21 @@ +setName('Resell.biz') + ->setDescription( + 'Resell.biz provides low-cost domain registration, ' + . 'domain management, and hosting services for thousands of resellers worldwide' + ); + } +} diff --git a/src/ResellerClub/Provider.php b/src/ResellerClub/Provider.php new file mode 100644 index 0000000..050c108 --- /dev/null +++ b/src/ResellerClub/Provider.php @@ -0,0 +1,22 @@ +setName('ResellerClub') + ->setDescription( + 'ResellerClub offers a comprehensive solution to register and ' + . 'manage 500+ gTLDs, ccTLDs and new domains.' + ) + ->setLogoUrl('https://api.upmind.io/images/logos/provision/resellerclub-logo_2x.png'); + } +} diff --git a/src/Ricta/Data/Configuration.php b/src/Ricta/Data/Configuration.php new file mode 100644 index 0000000..25cf407 --- /dev/null +++ b/src/Ricta/Data/Configuration.php @@ -0,0 +1,22 @@ + ['required', 'string', 'min:1'], + 'epp_password' => ['required', 'string', 'min:6'], + ]); + } +} diff --git a/src/Ricta/Provider.php b/src/Ricta/Provider.php new file mode 100644 index 0000000..2da4834 --- /dev/null +++ b/src/Ricta/Provider.php @@ -0,0 +1,49 @@ +configuration = $configuration; + } + + protected function getSupportedTlds(): array + { + return ['rw']; + } + + protected function makeClient(): Client + { + return new Client( + $this->configuration->epp_username, + $this->configuration->epp_password, + 'registry.ricta.org.rw', + 700, + __DIR__ . '/cert.pem', + $this->getLogger() + ); + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('RICTA') + ->setDescription('Register, transfer, renew and manage RICTA .rw domains'); + } +} diff --git a/src/Ricta/cert.pem b/src/Ricta/cert.pem new file mode 100644 index 0000000..9084fde --- /dev/null +++ b/src/Ricta/cert.pem @@ -0,0 +1,58 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqMcejxRrpO +c2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4PiUifsz0 +Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi8eABg97A +0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs/pQPEX6q +IC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XDx+KUnv+D +6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABAoIBADaynUAqyjH2LGMB +mKbe133Zg0tSgFuOWaBuOnUHQkMzjuLOMX3VrBVBBRQrD93FEQNvYbud/LWk5RmK +yHafA2RrA43R1diX3NUqJhErTmMSwZuoy6d9zZlLH8IpqG/3tj0Ztghx6BVGN0GQ +v40IJ0zFeq5X5TZyq1tnTAFld18W/E+4L5DmAn4ROoIscPh/u7ua/wrD1D4ooO5h +NWMXvEuA4o60TGeQCjzssEVzwgUVPKQBqhhB2kQrvpNqCKV/cLuBdqGb2VX9+hXm +zzIkGIkDSQ5+NhcfnBHVirsH3KNKX/m8z2GiBttuIN/OQqM84tN3kKTDe4aN0f9i +7lidZaECgYEA51HsCKTBOh9OZTu5lT3Ev1dcerSIKSlDk80TnmBBsNr8ctIQSID5 +28ARU1L+CH7+06XIEbcBThCHrJNMBA+PE/Ykqw64zzXk7ik1tCly/hFSVKpZD6Wp +lEjlYESCqL8UKg9MoVjc8cHnLf4smG2X+SQMDaqneIJZy8neuhsS4bUCgYEA2mvX +eHYQNUit05hweUR7VtZxuSLSOvyHvAxrEdG22eYMLk6LF9y9IC2yWpz/cdWHMqLq +3unA42OS4/tSqxoSKYBSdoyFGWX5NltFCzZPXd78D8FVz8U626X28L21bcgVcwCd +EJ8aU+zhzqixSkC9dxDX6HJcxfbaVbucNoSca9kCgYEAwHSaSr64vSDa2sMMLq0L +ip6mpLibKJPaU5gmIHi5buljbCx1u70DJN/yCj9cd7khTvn5MTPvdAGwv9Z1QlOn +mNYLv/4pqMyQQc4rjk+GCvhiZWqtWqVcJ7FWlfeqNbd0kWHVQdBrUwEe1FdKxy83 +Z+Oj26MGXu8kwracBn8MAJkCgYAIHmIP9DN+B4mOh+gGWelLvQTVINo3nxNchgmk +y+rEBq0FO54n8OiGvawXeiZ0kL9JvoyEZKPqz9Sx7LGR8pIiQMbP6UE5RHUS9CmI +1Sf2EUfFPiZ2Zppdd7nKEQMhZYKGl8s+xusvm2p5SAPvAqEIP/QGi9mu8hIDhcm0 +rREzYQKBgDfe4Z+bVD13ASXtJ+C7gOmaIQTQI/68DIT2eFR6IOc+LM1K1o8u/KQy +NNWaOIiVe7oXWOg59HmMH7bkra/I/qBb4Fzb4QOhLxXlMthTmG2dJBrQJMLoirHX +BIWiYoeRv3V+H6p4oGP+g4LAfYleEcAUij6YQLy9oUwbusZlFeWs +-----END RSA PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFZjCCBE6gAwIBAgIQK+MtAo2IygRd0TCN2bO5QDANBgkqhkiG9w0BAQsFADCB +kDELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G +A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxNjA0BgNV +BAMTLUNPTU9ETyBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBD +QTAeFw0xNjA5MjIwMDAwMDBaFw0xNzA5MjIyMzU5NTlaMF4xITAfBgNVBAsTGERv +bWFpbiBDb250cm9sIFZhbGlkYXRlZDEhMB8GA1UECxMYUG9zaXRpdmVTU0wgTXVs +dGktRG9tYWluMRYwFAYDVQQDEw1nYXJhbm50b3IuY29tMIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEAxV0076o5tr3UsJrd08OYf8R7B4Jw3sn9+zvmgaqM +cejxRrpOc2xCZgg8ZeCuq/7MhQCJt9wl4YzUWu/G9wOtytdHxG17Z3baAFojXig4 +PiUifsz0Lb5OcA/p3IegZ7z/Sul6jfzvUIbO4JVOPGe9p90JpvyY0YATJDfc1GXi +8eABg97A0EBzHTk+3e5ePbRFhwU5CewzJ5izE1rptJKzMd0JI799ARsCtTAKmOZs +/pQPEX6qIC/e3A0vcpglK4UN3VSWXfOEQM6Y7+SSM0Crlrfu+z2BC371fEtY87XD +x+KUnv+D6eta5sv6sRZTklIyjDnelHvs3cX9ULkitaP5bQIDAQABo4IB6zCCAecw +HwYDVR0jBBgwFoAUkK9qOpRaC9iQ6hJWc99DtDoo2ucwHQYDVR0OBBYEFEk8baOc +uKUuA4ORiEI3AXdZGXBYMA4GA1UdDwEB/wQEAwIFoDAMBgNVHRMBAf8EAjAAMB0G +A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjBPBgNVHSAESDBGMDoGCysGAQQB +sjEBAgIHMCswKQYIKwYBBQUHAgEWHWh0dHBzOi8vc2VjdXJlLmNvbW9kby5jb20v +Q1BTMAgGBmeBDAECATBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLmNvbW9k +b2NhLmNvbS9DT01PRE9SU0FEb21haW5WYWxpZGF0aW9uU2VjdXJlU2VydmVyQ0Eu +Y3JsMIGFBggrBgEFBQcBAQR5MHcwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuY29t +b2RvY2EuY29tL0NPTU9ET1JTQURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJD +QS5jcnQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTA5BgNV +HREEMjAwgg1nYXJhbm50b3IuY29tgg8qLmdhcmFubnRvci5jb22CDiouZ2FyYW5u +dG9yLm5nMA0GCSqGSIb3DQEBCwUAA4IBAQBcvhvND3DnYZEytIWIDy/LbrkfzVOQ +GvGxsMQsNYLmYmsmnYLA4cBlZ2DIZbWehfMPXAbZgre2+Y8yOFZOzRR848hU1Kka +7Tf/XUMr/YEV+SwRiEjL3cX1HLXPXzB0DhAG/3rsK+ANePxWNCXLK5riBKSN1C45 +1cahmHRw4+DNccUTZAX6yOhWmNtgvYwbtAqxZfkIivaB4J7nuolG5d/FHMOpEqwL +TEBWT0I5fuGX2XO94Xw9zyEjhPF0Psiqzi+6Umv8lyelLr/NI3b3g2kfcvoM6KRe +6K3NgNT8eJGH4Tf2OM4AGd20FwqWwlVe86Nc9qYJQsGmMwzU2sfem+AQ +-----END CERTIFICATE----- \ No newline at end of file diff --git a/src/UGRegistry/Data/UGRegistryConfiguration.php b/src/UGRegistry/Data/UGRegistryConfiguration.php new file mode 100644 index 0000000..ad12e88 --- /dev/null +++ b/src/UGRegistry/Data/UGRegistryConfiguration.php @@ -0,0 +1,25 @@ + ['required', 'string'], + 'debug' => ['boolean'], + ]); + } +} diff --git a/src/UGRegistry/Provider.php b/src/UGRegistry/Provider.php new file mode 100644 index 0000000..8a0ec39 --- /dev/null +++ b/src/UGRegistry/Provider.php @@ -0,0 +1,580 @@ +configuration = $configuration; + } + + public static function aboutProvider(): AboutData + { + return AboutData::create() + ->setName('UG Registry') + ->setDescription('Register, transfer, renew and manage .ug domains with the Ugandan domain registry'); + } + + public function poll(PollParams $params): PollResult + { + throw $this->errorResult('Operation not supported'); + } + + public function domainAvailabilityCheck(DacParams $params): DacResult + { + $domains = []; + $dacDomains = []; + + foreach ($params->tlds as $tld) { + if (Str::endsWith($tld, ['ug'])) { + $domains[] = Utils::getDomain($params->sld, $tld); + } else { + $dacDomains[] = new DacDomain([ + 'domain' => Utils::getDomain($params->sld, $tld), + 'tld' => Str::start(Utils::normalizeTld($tld), '.'), + 'can_register' => false, + 'can_transfer' => false, + 'is_premium' => false, + 'description' => 'TLD not supported', + ]); + } + } + + $checkedDomains = $this->_callApi([ + 'domains' => array_map(function ($domain) { + return ['name' => $domain]; + }, $domains) + ], '/domains/check-availability', 'GET'); + + foreach ($checkedDomains['data'] as $check) { + $domainParts = explode('.', $check['domain'], 2); + $tld = $domainParts[1]; + + $dacDomains[] = new DacDomain([ + 'domain' => $check['domain'], + 'tld' => Str::start(Utils::normalizeTld($tld), '.'), + 'can_register' => !!$check['available'], + 'can_transfer' => !$check['available'], + 'is_premium' => false, + 'description' => sprintf( + 'Domain is %s to register', + $check['available'] ? 'available' : 'not available' + ), + ]); + } + + return new DacResult([ + 'domains' => $dacDomains, + ]); + } + + public function register(RegisterDomainParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $checkedDomains = $this->_callApi([ + 'domains' => [['name' => $domain]] + ], '/domains/check-availability', 'GET'); + + if (empty($checkedDomains['data'][0]['available'])) { + return $this->errorResult('This domain is not available to register'); + } + + $data = [ + 'domain_name' => $domain, + 'period' => intval($params->renew_years), + ]; + + $this->_callApi($data, '/domains/register', 'POST'); + + $ns = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'nameservers.ns' . $i)) { + $ns['ns' . $i] = [ + 'name' => Arr::get($params, 'nameservers.ns' . $i . '.host') + ]; + } + } + + $contacts = [ + 'contacts' => [ + 'registrant' => $this->_prepareContact($params->registrant->register, 'registrant'), + 'admin' => $this->_prepareContact($params->admin->register, 'admin'), + 'billing' => $this->_prepareContact($params->billing->register, 'billing'), + 'tech' => $this->_prepareContact($params->tech->register, 'tech'), + ] + ]; + + $this->_updateDomain($domain, $contacts, $ns); + + return $this->_getDomain($domain) + ->setMessage('Domain registered'); + } + + /** + * @param TransferParams $params + * @return DomainResult + */ + public function transfer(TransferParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + try { + return $this->_getDomain($domain, true, true) + ->setMessage('Domain active in registrar account'); + } catch (Throwable $e) { + $this->_callApi( + [ + 'domain_name' => $domain, + ], + '/domains/request-transfer', + 'POST' + ); + + throw $this->errorResult('Domain transfer requested'); + } + } + + public function release(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported'); + } + + public function renew(RenewParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + $this->_renewDomain($domain, intval($params->renew_years)); + + return $this->_getDomain($domain) + ->setMessage('Domain renewed'); + } + + public function getInfo(DomainInfoParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + + return $this->_getDomain($domain, true); + } + + public function updateNameservers(UpdateNameserversParams $params): NameserversResult + { + $this->_updateRegisteredNameServer($params); + + $domain = $this->_getDomain(Utils::getDomain($params->sld, $params->tld)); + + return NameserversResult::create($domain->ns) + ->setMessage('Nameservers updated'); + } + + public function getEppCode(EppParams $params): EppCodeResult + { + throw $this->errorResult('Operation not supported'); + } + + public function updateIpsTag(IpsTagParams $params): ResultData + { + throw $this->errorResult('Operation not supported'); + } + + public function updateRegistrantContact(UpdateDomainContactParams $params): ContactResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $domainData = $this->_callApi( + [ + 'domain_name' => $domainName, + ], + '/domains/whois' + ); + + $ns = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (!empty($domainData['data']['domain']['contacts']['nameservers']['ns' . $i])) { + $ns['ns' . $i] = [ + 'name' => $domainData['data']['domain']['contacts']['nameservers']['ns' . $i] + ]; + } + } + + $contacts = [ + 'contacts' => [ + 'registrant' => $this->_prepareContact($params->contact, 'registrant'), + 'admin' => $domainData['data']['domain']['contacts']['admin'], + 'billing' => $domainData['data']['domain']['contacts']['billing'], + 'tech' => $domainData['data']['domain']['contacts']['tech'] + ] + ]; + + $this->_updateDomain($domainName, $contacts, $ns); + + $domainData = $this->_callApi( + [ + 'domain_name' => $domainName, + ], + '/domains/whois' + ); + + return $this->_parseContactInfo($domainData['data']['domain']['contacts']['registrant']); + } + + public function setLock(LockParams $params): DomainResult + { + $domain = Utils::getDomain($params->sld, $params->tld); + $message = sprintf('Domain %s', $params->lock ? 'locked' : 'unlocked'); + + try { + $this->_toggleLock($domain, !!$params->lock); + } catch (Throwable $e) { + if (Str::contains($e->getMessage(), ['is already locked'])) { + $message = 'Domain already locked'; + } elseif (Str::contains($e->getMessage(), ['is already unlocked'])) { + $message = 'Domain already unlocked'; + } else { + throw $e; + } + } + + return $this->_getDomain($domain) + ->setLocked(!!$params->lock) + ->setMessage($message); + } + + public function setAutoRenew(AutoRenewParams $params): DomainResult + { + $domainName = Utils::getDomain($params->sld, $params->tld); + $domainData = $this->_getDomain(Utils::getDomain($params->sld, $params->tld)); + if ($domainData['renew'] == $params->auto_renew) { + return $this->errorResult(sprintf('Domain already is set to %s', $domainData['renew'] ? 'auto-renew' : 'not auto-renew'), $params); + } + if ($params->auto_renew == true) { + $path = 'addAutoRenewal'; + } else { + $path = 'removeAutoRenewal'; + } + + $this->_callApi( + [ + 'domain' => $domainName, + ], + $path + ); + + return $this->_getDomain($domainName) + ->setMessage('Domain auto-renew mode updated'); + } + + private function _callApi(array $params, string $path, string $method = 'GET'): array + { + $url = $this->baseUrl; + $url .= $path ; + $paramKey = 'json'; + + // if ($method == 'GET') { + // $paramKey = 'query'; + // } + + $client = new Client(['handler' => $this->getGuzzleHandlerStack(!!$this->configuration->debug)]); + + $headers = ['Authorization' => 'Bearer ' . $this->configuration->api_key]; + + $response = $client->request( + $method, + $url, + [ + $paramKey => $params, + 'http_errors' => false, + 'headers' => $headers + ] + ); + + $responseData = json_decode($response->getBody()->__toString(), true); + + if ($response->getStatusCode() >= 300 || empty($responseData)) { + throw $this->_handleApiErrorResponse($response, $responseData); + } + + return $responseData; + } + + private function _getDomain( + string $domainName, + bool $verifyOwnership = false, + bool $assertActive = false + ): DomainResult { + $domainData = $this->_callApi( + [ + 'domain_name' => $domainName, + ], + '/domains/whois' + ); + + if ($verifyOwnership) { + $this->_verifyOwnership($domainName, $domainData); + } + + if ($assertActive && $domainData['data']['domain']['status'] != 'ACTIVE') { + throw $this->errorResult('Domain is not active', ['response_data' => $domainData]); + } + + $ns = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (!empty($domainData['data']['domain']['contacts']['nameservers']['ns' . $i])) { + $ns['ns' . $i] = [ + 'host' => $domainData['data']['domain']['contacts']['nameservers']['ns' . $i] + ]; + } + } + + $info = DomainResult::create([ + 'id' => 'N/A', + 'domain' => $domainName, + 'statuses' => [ucfirst(strtolower($domainData['data']['domain']['status']))], + 'registrant' => $this->_parseContactInfo($domainData['data']['domain']['contacts']['registrant']), + 'billing' => $this->_parseContactInfo($domainData['data']['domain']['contacts']['billing']), + 'admin' => $this->_parseContactInfo($domainData['data']['domain']['contacts']['admin']), + 'tech' => $this->_parseContactInfo($domainData['data']['domain']['contacts']['tech']), + 'ns' => $ns, + 'created_at' => Utils::formatDate($domainData['data']['domain']['registration_date']), + 'updated_at' => null, + 'expires_at' => Utils::formatDate($domainData['data']['domain']['expiry_date']), + ])->setMessage('Domain info retrieved'); + + return $info; + } + + private function _parseContactInfo(array $contact): ContactResult + { + return ContactResult::create([ + 'name' => trim($contact['firstname'] . ' ' . ($contact['lastname'] ?? '')), + 'email' => $contact['email'], + 'phone' => Utils::localPhoneToInternational($contact['phone'], $contact['country'], false), + 'organisation' => !empty($contact['organization']) ? $contact['organization'] : '', + 'address1' => $contact['street_address'], + 'city' => $contact['city'], + 'state' => $contact['state_province'] ?? null, + 'postcode' => $contact['postal_code'], + 'country_code' => Utils::countryToCode($contact['country']), + ]); + } + + /** + * Renew domain + * + * @param string $domainName + * @return boolean + */ + private function _renewDomain(string $domainName, int $renew_years): void + { + $this->_callApi( + [ + 'domain_name' => $domainName, + 'period' => $renew_years, + ], + '/domains/renew', + 'POST' + ); + } + + private function _updateRegisteredNameServer(UpdateNameserversParams $params): void + { + $domain = Utils::getDomain($params->sld, $params->tld); + $domainData = $this->_callApi( + [ + 'domain_name' => $domain, + ], + '/domains/whois' + ); + + $ns = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (Arr::has($params, 'ns' . $i)) { + $ns['ns' . $i] = [ + 'name' => Arr::get($params, 'ns' . $i . '.host') + ]; + } + } + + $contacts = [ + 'contacts' => [ + 'registrant' => $domainData['data']['domain']['contacts']['registrant'], + 'admin' => $domainData['data']['domain']['contacts']['admin'], + 'billing' => $domainData['data']['domain']['contacts']['billing'], + 'tech' => $domainData['data']['domain']['contacts']['tech'] + ] + ]; + + $this->_updateDomain($domain, $contacts, $ns); + } + + /** + * @throws ProvisionFunctionError + * + * @return no-return + */ + private function _handleApiErrorResponse(Response $response, ?array $responseData): void + { + $errorMessage = $responseData['error'] ?? $responseData['message'] ?? null; + + if (!$errorMessage && isset($responseData['data']['domain_name'])) { + $errorMessage = sprintf('Domain name %s', implode(',', $responseData['data']['domain_name'])); + } + + $message = sprintf('Provider Error: %s', trim($errorMessage ?? 'Unknown error', '.')); + + throw $this->errorResult($message, [ + 'http_code' => $response->getStatusCode(), + 'response_data' => $responseData, + ]); + } + + private function _prepareContact(?ContactParams $register, string $type): array + { + $data = []; + $data['firstname'] = $register->name ?? $register->organisation; + if ($type != 'registrant') { + $nameParts = explode(' ', $register->name ?? $register->organisation, 2); + $data['firstname'] = $nameParts[0]; + $data['lastname'] = $nameParts[1] ?? ''; + } + $data['email'] = $register->email; + $data['organization'] = $register->organisation; + $data['country'] = Utils::codeToCountry($register->country_code); + $data['city'] = $register->city; + $data['street_address'] = $register->address1; + $data['phone'] = $register->phone; + $data['postal_code'] = $register->postcode; + $data['fax'] = ""; + + return array_map(function ($value) { + return $value ?? ''; + }, $data); + } + + private function _updateDomain(string $domain, array $contacts, array $nameservers) + { + $params = []; + $params['domain_name'] = $domain; + $params += $contacts; + $params['nameservers'] = $nameservers; + + $this->_callApi($params, '/domains/modify', 'POST'); + } + + /** + * Verify the given domain is owned by the current account the only way we know how: + * attempt to update the domain without any changes. + */ + private function _verifyOwnership(string $domain, ?array $domainData = null): void + { + $domainData = $domainData ?? $this->_callApi( + [ + 'domain_name' => $domain, + ], + '/domains/whois' + ); + + $ns = []; + for ($i = 1; $i <= self::MAX_CUSTOM_NAMESERVERS; $i++) { + if (!empty($domainData['data']['domain']['contacts']['nameservers']['ns' . $i])) { + $ns['ns' . $i] = [ + 'name' => $domainData['data']['domain']['contacts']['nameservers']['ns' . $i] + ]; + } + } + + $contacts = [ + 'contacts' => [ + 'registrant' => $domainData['data']['domain']['contacts']['registrant'], + 'admin' => $domainData['data']['domain']['contacts']['admin'], + 'billing' => $domainData['data']['domain']['contacts']['billing'], + 'tech' => $domainData['data']['domain']['contacts']['tech'] + ] + ]; + + try { + $this->_updateDomain($domain, $contacts, []); + } catch (ProvisionFunctionError $e) { + if (Str::contains($e->getMessage(), 'domain is locked')) { + return; + } + + if (Str::contains($e->getMessage(), 'not authorized')) { + throw $this->errorResult('Domain is not owned by this account', $e->getData(), $e->getDebug(), $e); + } + + throw $e; + } + } + + private function _toggleLock(string $domain, bool $locked): void + { + $path = $locked ? '/domains/lock' : '/domains/unlock'; + + $this->_callApi([ + 'domain_name' => $domain, + ], $path, 'POST'); + } +}