diff --git a/.gitignore b/.gitignore index 8f2077f..05992f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea vendor/ -composer.lock \ No newline at end of file +composer.lock +tests/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b9761a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Bohdan Konkevych + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/composer.json b/composer.json index 2aa4258..1d60708 100644 --- a/composer.json +++ b/composer.json @@ -5,9 +5,13 @@ "homepage": "http://git.devbones.com/Toloka/php-api", "require": { "php": ">=7.4", - "psr/simple-cache": "^3.0", - "psr/log": "^3.0", - "psr/http-client": "^1.0" + "psr/http-client": "^1.0", + "psr/simple-cache": "^1.0", + "psr/log": "^1.0", + "symfony/dom-crawler": "^5.4", + "symfony/css-selector": "^5.4", + "dflydev/fig-cookies": "^3.0", + "symfony/polyfill-php80": "^1.26" }, "license": "MIT", "autoload": { @@ -22,6 +26,9 @@ } ], "require-dev": { - "guzzlehttp/guzzle": "^7.5" + "cache/array-adapter": "^1.2", + "guzzlehttp/guzzle": "^7.5", + "monolog/monolog": "^2.8", + "cache/filesystem-adapter": "^1.2" } } diff --git a/src/Client.php b/src/Client.php index 69d2c8c..2963ca9 100644 --- a/src/Client.php +++ b/src/Client.php @@ -2,12 +2,24 @@ namespace Toloka\PhpApi; +use Dflydev\FigCookies\Cookie; +use Dflydev\FigCookies\Cookies; +use Dflydev\FigCookies\FigRequestCookies; +use Dflydev\FigCookies\SetCookies; +use GuzzleHttp\Psr7\Request; use Psr\Http\Client\ClientInterface as HttpClientInterface; +use Psr\Http\Message\RequestInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; +use GuzzleHttp\Psr7\Utils; +use Symfony\Component\DomCrawler\Crawler; +use Toloka\PhpApi\Exception\Auth\AuthException; +use Toloka\PhpApi\Exception\Auth\InvalidAuthCredentials; class Client implements ClientInterface { + const CACHE_KEY_COOKIES = 'cookies'; + /** * Hurtom Toloka base url. * @@ -36,6 +48,11 @@ class Client implements ClientInterface { */ protected LoggerInterface $logger; + /** + * @var Cookies + */ + protected ?Cookies $cookies = NULL; + /** * Toloka client constructor. * @@ -44,14 +61,22 @@ class Client implements ClientInterface { * @param LoggerInterface|NULL $logger */ public function __construct( - string $base_url = 'https://toloka.to', + string $base_url, HttpClientInterface $httpClient, CacheInterface $cache, LoggerInterface $logger ) { + $this->base_url = $base_url; $this->httpClient = $httpClient; $this->cache = $cache; $this->logger = $logger; + + if ($cache->has(self::CACHE_KEY_COOKIES)) { + $persistent_cookies = $cache->get(self::CACHE_KEY_COOKIES); + if ($persistent_cookies instanceof Cookies) { + $this->cookies = $persistent_cookies; + } + } } /** @@ -65,14 +90,79 @@ class Client implements ClientInterface { * {@inheritDoc} */ public function login(string $login, string $password): void { - // TODO: Implement login() method. + if ($this->isLoggedIn()) { + return; + } + $body = http_build_query([ + 'username' => $login, + 'password' => $password, + 'autologin' => 'on', + 'ssl' => 'on', + 'redirect' => '', + 'login' => 'Вхід', + ]); + $request = new Request('POST', $this->getBaseUrl() . '/login.php', [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], Utils::streamFor($body)); + $response = $this->httpClient->sendRequest($request); + if ($response->getStatusCode() === 302) { + // If redirected - this means successful login, can save cookies. + $cookies = SetCookies::fromResponse($response); + $this->saveCookies($cookies); + } + elseif ($response->getStatusCode() === 200) { + // In case of returned page - try to find an error and throw exception. + $crawler = new Crawler($response->getBody()->getContents()); + $text = $crawler->filter('.forumline .row1 span.gen')->text(NULL, TRUE); + if (str_contains($text, 'Такий псевдонім не існує, або не збігається пароль.')) { + throw new InvalidAuthCredentials(); + } + } + throw new AuthException(); + } + + private function saveCookies($data) { + if ($data instanceof SetCookies) { + $cookies = []; + foreach ($data->getAll() as $cookie) { + $cookies[] = Cookie::create($cookie->getName(), $cookie->getValue()); + } + $this->cookies = new Cookies($cookies); + } + elseif ($data instanceof Cookies) { + $this->cookies = $data; + } + $this->cache->set(self::CACHE_KEY_COOKIES, $this->cookies); + } + + private function applyRequestCookies(RequestInterface $request): RequestInterface { + if (!$this->cookies) { + return $request; + } + foreach ($this->cookies->getAll() as $cookie) { + $request = FigRequestCookies::set($request, $cookie); + } + return $request; } /** * {@inheritDoc} */ public function isLoggedIn(): bool { - // TODO: Implement isLoggedIn() method. + $request = new Request('GET', $this->getBaseUrl()); + $request = $this->applyRequestCookies($request); + $response = $this->httpClient->sendRequest($request); + if ($response->getStatusCode() === 200) { + $crawler = new Crawler($response->getBody()->getContents()); + $menu_links = $crawler->filter('table.navie6fix a'); + foreach ($menu_links as $menu_link) { + // If there is a link to profile, then we authorized. + if ($menu_link->textContent === 'Профіль') { + return TRUE; + } + } + } + return FALSE; } /** @@ -86,7 +176,7 @@ class Client implements ClientInterface { * {@inheritDoc} */ public function getTopic(int $id): TopicInterface { - // TODO: Implement getTopic() method. + } } diff --git a/src/ClientInterface.php b/src/ClientInterface.php index 041c70f..8d27d20 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -30,6 +30,8 @@ interface ClientInterface { * Check if user logged in. * * @return bool + * + * @throws \Psr\Http\Client\ClientExceptionInterface */ public function isLoggedIn(): bool; @@ -48,6 +50,8 @@ interface ClientInterface { * * @return TopicInterface * Object with populated topic data. + * + * @throws \Psr\Http\Client\ClientExceptionInterface */ public function getTopic(int $id): TopicInterface; diff --git a/src/Exception/Auth/AuthException.php b/src/Exception/Auth/AuthException.php new file mode 100644 index 0000000..8fd2a25 --- /dev/null +++ b/src/Exception/Auth/AuthException.php @@ -0,0 +1,5 @@ +