Разделение репозитория
This commit is contained in:
commit
37de707b50
|
@ -0,0 +1,2 @@
|
||||||
|
/vendor
|
||||||
|
!.gitignore
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Менеджер аккаунтов
|
||||||
|
Инициализирует аккаунты для их использования в колпачных фреймворках и библеотеках
|
||||||
|
|
||||||
|
### Установка:
|
||||||
|
```sh
|
||||||
|
$ composer install hood/accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пример использования:
|
||||||
|
```php
|
||||||
|
use hood\accounts\vk;
|
||||||
|
|
||||||
|
// Подключение библеотек
|
||||||
|
require_once './vendor/autoload.php';
|
||||||
|
|
||||||
|
// Инициализация пользователя ВКонтакте
|
||||||
|
$account = (new vk($id))->auth('login', 'password')->key($project_id);
|
||||||
|
|
||||||
|
// Вывести сгенерированный ключ
|
||||||
|
echo $account->key;
|
||||||
|
```
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "mirzaev/accounts-vk",
|
||||||
|
"type": "library",
|
||||||
|
"description": "Менеджер аккаунтов ВКонтакте",
|
||||||
|
"keywords": [
|
||||||
|
"accounts",
|
||||||
|
"vk"
|
||||||
|
],
|
||||||
|
"homepage": "https://git.hood.su/mirzaev/accounts/vk",
|
||||||
|
"license": "WTFPL",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Arsen Mirzaev",
|
||||||
|
"email": "red@hood.su",
|
||||||
|
"homepage": "https://hood.su/mirzaev",
|
||||||
|
"role": "Programmer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"docs": "https://git.hood.su/mirzaev/accounts/vk/manual",
|
||||||
|
"issues": "https://git.hood.su/mirzaev/accounts/vk/issues",
|
||||||
|
"chat": "https://vk.me/darkweb228"
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "~8.0",
|
||||||
|
"mirzaev/accounts": "0.1.x-dev"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpdocumentor/phpdocumentor": ">=2.9",
|
||||||
|
"phpunit/phpunit": "^9"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"mirzaev\\accounts\\vk\\": "mirzaev/accounts-vk/system"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"mirzaev\\accounts\\vk\\tests\\": "mirzaev/accounts-vk/tests"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,2 @@
|
||||||
|
/*
|
||||||
|
!.gitignore
|
|
@ -0,0 +1,746 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace mirzaev\accounts;
|
||||||
|
|
||||||
|
use DOMDocument,
|
||||||
|
DOMXPath;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
use mirzaev\accounts\auth\basic;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client as browser,
|
||||||
|
GuzzleHttp\Cookie\FileCookieJar,
|
||||||
|
GuzzleHttp\TransferStats;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Попка
|
||||||
|
*
|
||||||
|
* @see https://vk.com/apps Управление приложениями (параметры $id и $secret для приложения)
|
||||||
|
* @see https://vk.com/dev/permissions Разрешения для приложения (параметр $scope)
|
||||||
|
*
|
||||||
|
* @todo
|
||||||
|
* 1. Вернуть внутреннее хранение cookies, а выгрузку в файл сделать отдельным методом: "dump();".
|
||||||
|
* $this->cookies - строка cookie, $this->root_path - корневая директория (которая сейчас $this->path), $this->cookies_path - путь до файла хранящего cookies
|
||||||
|
* 2. Сделать возможность авторизации без псевдонима и пароля, указав место хранения файла cookies
|
||||||
|
* 4. Добавить возможность авторизации через сторонний браузер, который более походит на настоящий (низкий приоритет)
|
||||||
|
* 5. Создать debug-режим в котором будут сохранены обрабатываемые html страницы и действия будут записываться по PSR-7 в журнал (низкий приоритет)
|
||||||
|
*/
|
||||||
|
final class vk extends account implements basic
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Статус аккаунта
|
||||||
|
*/
|
||||||
|
private string $status = 'unauthenticated';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Идентификатор аккаунта
|
||||||
|
*/
|
||||||
|
private int $id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Псевдоним аккаунта
|
||||||
|
*/
|
||||||
|
private string $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пароль аккаунта
|
||||||
|
*/
|
||||||
|
private string $password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ доступа (клиентский)
|
||||||
|
*
|
||||||
|
* Implicit Flow
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/implicit_flow_user
|
||||||
|
*/
|
||||||
|
private string $client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ доступа (серверный)
|
||||||
|
*
|
||||||
|
* Authorization Code Flow
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/authcode_flow_user
|
||||||
|
*/
|
||||||
|
private string $server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ доступа (доверенный)
|
||||||
|
*
|
||||||
|
* Прямая авторизация
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/auth_direct
|
||||||
|
*/
|
||||||
|
private string $trusted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Перенаправление
|
||||||
|
*
|
||||||
|
* Для запросов при аутентификации и получении ключа доступа
|
||||||
|
*/
|
||||||
|
private string $redirect = 'https://oauth.vk.com/blank.html';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип отображения страницы аутентификации
|
||||||
|
*
|
||||||
|
* Для запросов при аутентификации и получении ключа доступа
|
||||||
|
*
|
||||||
|
* Варианты: mobile, page, popup
|
||||||
|
*/
|
||||||
|
private string $display = 'mobile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Тип отображения страницы аутентификации
|
||||||
|
*
|
||||||
|
* Для запросов при аутентификации и получении ключа доступа
|
||||||
|
*
|
||||||
|
* Параметр, указывающий, что необходимо не пропускать этап подтверждения прав, даже если пользователь уже авторизован
|
||||||
|
*/
|
||||||
|
private bool $revoke = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерировать данные параметра scope в виде строки, вместо битовой маски
|
||||||
|
*
|
||||||
|
* Может использоваться для унификации действий робота
|
||||||
|
*/
|
||||||
|
private bool $scope_as_string = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конструктор
|
||||||
|
*
|
||||||
|
* @param int $id Идентификатор
|
||||||
|
* @param string|null $path Корневой каталог аккаунтов
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function __construct(int $id, string $path = null)
|
||||||
|
{
|
||||||
|
// Идентификатор
|
||||||
|
$this->id = $id;
|
||||||
|
|
||||||
|
// Инициализация директории пользователя
|
||||||
|
if (isset($path)) {
|
||||||
|
// Если передан путь и он существует
|
||||||
|
$this->path = $path . DIRECTORY_SEPARATOR . $id;
|
||||||
|
} else {
|
||||||
|
// Иначе путь по умолчанию
|
||||||
|
$this->path = __DIR__ . DIRECTORY_SEPARATOR . 'accounts' . DIRECTORY_SEPARATOR . $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка и создание директории
|
||||||
|
if (!file_exists($this->path)) {
|
||||||
|
mkdir($this->path, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация браузера
|
||||||
|
$this->browser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аутентификация
|
||||||
|
*
|
||||||
|
* Аутентифицируется ВКонтакте и сохраняет cookie слабо эмулируя браузер
|
||||||
|
*
|
||||||
|
* @param string $name Псевдоним
|
||||||
|
* @param string $password Пароль
|
||||||
|
* @param int $mode Режим
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*
|
||||||
|
* @todo
|
||||||
|
* 1. Добавить проверку требования двухэтапной аутентификации
|
||||||
|
* 2. Добавить проверку требования ввода капчи
|
||||||
|
* 3. Добавить проверку неудачного ввода пароля
|
||||||
|
* 4. Добавить аутентификацию через версию для ПК
|
||||||
|
* 5. Добавить идентификацию капчи, решение капчи и тесты с капчей
|
||||||
|
* 6. Добавить возможность аутентификации без учёта имеющихся cookie (для эмуляции разных устройств)
|
||||||
|
*/
|
||||||
|
public function auth(string $name, string $password, int $mode = 0): self
|
||||||
|
{
|
||||||
|
if ($this->status === 'unauthenticated') {
|
||||||
|
// Аккаунт не аутентифицирован
|
||||||
|
|
||||||
|
// Инициализация свойств
|
||||||
|
$this->name = $name;
|
||||||
|
$this->password = $password;
|
||||||
|
|
||||||
|
if ($mode === 0) {
|
||||||
|
// Режим мобильной версии (по умолчанию)
|
||||||
|
|
||||||
|
// Запрос страницы с аутентификацией
|
||||||
|
$response = $this->browser->request('GET', 'https://m.vk.com');
|
||||||
|
|
||||||
|
// Проверка на ошибки
|
||||||
|
$body = $this->check((string) $response->getBody());
|
||||||
|
|
||||||
|
if ($response->getStatusCode() === 200) {
|
||||||
|
// Инициализация DOM
|
||||||
|
$dom = new DOMDocument;
|
||||||
|
@$dom->loadHTML($body);
|
||||||
|
|
||||||
|
if (empty($dom->getElementById('lm_prof_panel'))) {
|
||||||
|
// Панель с данными аккаунта не найдена (подразумевается, что не удалось аутентифицироваться)
|
||||||
|
|
||||||
|
// Поиск ссылки для отправки формы (аутентификация)
|
||||||
|
$action = $dom->getElementsByTagName('form')[0]->getAttribute('action');
|
||||||
|
} else {
|
||||||
|
// Панель с данными аккаунта найдена (подразумевается, что удалось аутентифицироваться)
|
||||||
|
|
||||||
|
// Переход в конец выполнения аутентификации
|
||||||
|
goto auth_success_end;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Exception('Не удалось получить страницу аутентификации: ' . $response->getReasonPhrase(), $response->getStatusCode());
|
||||||
|
}
|
||||||
|
} else if ($mode === 1) {
|
||||||
|
// Режим компьютерной версии
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$response = $this->browser->request(
|
||||||
|
'POST',
|
||||||
|
$action,
|
||||||
|
[
|
||||||
|
'form_params' => [
|
||||||
|
'email' => $name,
|
||||||
|
'pass' => $password
|
||||||
|
]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Поиск уведомления с ошибкой
|
||||||
|
$warning = $this->xpath((string) $response->getBody(), "//div[contains(@class, 'service_msg_box')]/div[contains(@class, 'service_msg service_msg_warning')]/text()");
|
||||||
|
|
||||||
|
if (!empty($warning[0]->textContent)) {
|
||||||
|
// Аутентификация не прошла и появилось окно с ошибкой
|
||||||
|
|
||||||
|
throw new Exception('ВКонтакте: "' . trim($warning[0]->textContent) . '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Аутентификация успешно завершена
|
||||||
|
auth_success_end:
|
||||||
|
|
||||||
|
// Запись статуса
|
||||||
|
$this->status = 'authenticated';
|
||||||
|
} else if ($this->status === 'authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован обычным методом
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован');
|
||||||
|
} else if ($this->status === 'direct authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован через прямую аутентификацию
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован через прямую аутентификацию');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @todo Сделать
|
||||||
|
*/
|
||||||
|
public function deauth(): self
|
||||||
|
{
|
||||||
|
// Очистка cookie
|
||||||
|
if (file_exists($this->path . DIRECTORY_SEPARATOR . 'cookie.txt')) {
|
||||||
|
// Если сущестуют cookie, то удалить
|
||||||
|
unlink($this->path . DIRECTORY_SEPARATOR . 'cookie.txt');
|
||||||
|
|
||||||
|
// Ренициализация браузера
|
||||||
|
$this->browser();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ доступа для работы от лица клиентского приложения (Implicit Flow)
|
||||||
|
*
|
||||||
|
* С этим ключом не будут работать методы помеченные как доступные только для standalone-приложений
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/implicit_flow_user Информация про данный метод
|
||||||
|
*
|
||||||
|
* @param int $id Идентификатор приложения
|
||||||
|
* @param string|null ...$scope Разрешения
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*
|
||||||
|
* @todo
|
||||||
|
* 1. Сделать проверку через state
|
||||||
|
* 2. Запись данные ответа помимо access_token (expires_in и user_id=)
|
||||||
|
*/
|
||||||
|
public function client(int $id, int|string $scope): self
|
||||||
|
{
|
||||||
|
if ($this->status === 'authenticated') {
|
||||||
|
// Аккаунт аутентифицирован обычным методом
|
||||||
|
|
||||||
|
// Инициализация буфера URI
|
||||||
|
$uri = '';
|
||||||
|
|
||||||
|
// Инициализация разрешений
|
||||||
|
$scope = self::scope($scope, $this->scope_as_string);
|
||||||
|
|
||||||
|
// Запрос страницы подтверждения для генерации ключа
|
||||||
|
$response = $this->browser->request(
|
||||||
|
'POST',
|
||||||
|
"https://oauth.vk.com/authorize?client_id=$id&redirect_uri=$this->redirect&display=$this->display&scope=$scope&response_type=token&revoke=$this->revoke",
|
||||||
|
[
|
||||||
|
'http_errors' => false,
|
||||||
|
'on_stats' => function (TransferStats $stats) use (&$uri) {
|
||||||
|
$uri = $stats->getEffectiveUri();
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверка на наличие ошибок
|
||||||
|
$body = $this->check((string) $response->getBody());
|
||||||
|
|
||||||
|
if ($response->getStatusCode() === 200) {
|
||||||
|
// Успешно выполнен запрос
|
||||||
|
|
||||||
|
// Поиск текста
|
||||||
|
$text = $this->xpath($body, "/html/body/text()|/html/body/b/text()");
|
||||||
|
|
||||||
|
// Инкрементация найденных строк в одну
|
||||||
|
for ($body = '', $i = 1; $i < count($text); $body .= $text[$i++]->textContent);
|
||||||
|
|
||||||
|
// Обрезка переносов строк и пробелов
|
||||||
|
$body = trim($body);
|
||||||
|
|
||||||
|
if ($body !== "Пожалуйста, не копируйте данные из адресной строки для сторонних сайтов. Таким образом Вы можете потерять доступ к Вашему аккаунту.") {
|
||||||
|
// Получена страница с формой для подтверждения выдачи ключа
|
||||||
|
|
||||||
|
// Инициализация DOM
|
||||||
|
$dom = new DOMDocument;
|
||||||
|
@$dom->loadHTML((string) $response->getBody());
|
||||||
|
|
||||||
|
// Поиск ссылки для отправки формы (подтверждение выдачи ключа)
|
||||||
|
$action = $dom->getElementsByTagName('form')[0]->getAttribute('action');
|
||||||
|
|
||||||
|
// Запрос для подтверждения выдачи ключа
|
||||||
|
$response = $this->browser->request(
|
||||||
|
'POST',
|
||||||
|
$action,
|
||||||
|
[
|
||||||
|
'http_errors' => false,
|
||||||
|
'on_stats' => function (TransferStats $stats) use (&$uri) {
|
||||||
|
$uri = $stats->getEffectiveUri();
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверка на наличие ошибок
|
||||||
|
$this->check((string) $response->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлечение ключа из URI
|
||||||
|
parse_str(urldecode(parse_url((string) $uri)['query']), $query);
|
||||||
|
parse_str(parse_url($query["authorize_url"])["fragment"], $result);
|
||||||
|
|
||||||
|
// Запись ключа
|
||||||
|
$this->client = $result['access_token'];
|
||||||
|
}
|
||||||
|
} else if ($this->status === 'unauthenticated') {
|
||||||
|
// Аккаунт не аутентифицирован
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт не аутентифицирован');
|
||||||
|
} else if ($this->status === 'direct authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован через прямую аутентификацию
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован через прямую аутентификацию и не может получить клиентский ключ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ключ доступа для работы от лица сервера (Authorization Code Flow)
|
||||||
|
*
|
||||||
|
* С этим ключом возможности сильно ограничены, например нельзя отправлять сообщения
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/authcode_flow_user Информация про данный метод
|
||||||
|
*
|
||||||
|
* @param int $id Идентификатор приложения
|
||||||
|
* @param string|null ...$scope Разрешения
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*
|
||||||
|
* @todo
|
||||||
|
* 1. Сделать проверку через state
|
||||||
|
* 2. Запись данные ответа помимо access_token (expires_in и user_id=)
|
||||||
|
*/
|
||||||
|
public function server(int $id, string ...$scope): self
|
||||||
|
{
|
||||||
|
if ($this->status === 'authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован обычным методом
|
||||||
|
|
||||||
|
} else if ($this->status === 'unauthenticated') {
|
||||||
|
// Аккаунт не аутентифицирован
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт не аутентифицирован');
|
||||||
|
} else if ($this->status === 'direct authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован через прямую аутентификацию
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован через прямую аутентификацию и не может получить серверный ключ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Прямая аутентификация
|
||||||
|
*
|
||||||
|
* Доступ к этому типу аутентификации выдаётся администрацией ВКонтакте
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/auth_direct Информация про данный метод
|
||||||
|
*
|
||||||
|
* @param int $id Идентификатор приложения
|
||||||
|
* @param int $secret Секретный ключ приложения
|
||||||
|
* @param string|null ...$scope Разрешения
|
||||||
|
*
|
||||||
|
* @return self
|
||||||
|
*
|
||||||
|
* @todo
|
||||||
|
* 1. Сделать проверку через state
|
||||||
|
* 2. Запись данные ответа помимо access_token (expires_in и user_id=)
|
||||||
|
*/
|
||||||
|
public function direct(int $id, string $secret, string $name, string $password, string|int $scope): self
|
||||||
|
{
|
||||||
|
if ($this->status === 'unauthenticated') {
|
||||||
|
// Аккаунт не аутентифицирован
|
||||||
|
|
||||||
|
// Инициализация свойств
|
||||||
|
$this->name = $name;
|
||||||
|
$this->password = $password;
|
||||||
|
|
||||||
|
// Инициализация буфера URI
|
||||||
|
$uri = '';
|
||||||
|
|
||||||
|
// Инициализация разрешений
|
||||||
|
$scope = self::scope($scope, $this->scope_as_string);
|
||||||
|
|
||||||
|
// Запрос страницы подтверждения для генерации ключа
|
||||||
|
$response = $this->browser->request(
|
||||||
|
'POST',
|
||||||
|
"https://oauth.vk.com/access_token?grant_type=password&client_id=$id&client_secret=$secret&username=$name&password=$password&scope=$scope",
|
||||||
|
[
|
||||||
|
'http_errors' => false,
|
||||||
|
'on_stats' => function (TransferStats $stats) use (&$uri) {
|
||||||
|
$uri = $stats->getEffectiveUri();
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Проверка на наличие ошибок
|
||||||
|
$this->check((string) $response->getBody());
|
||||||
|
|
||||||
|
// Запись ключа
|
||||||
|
$this->trusted = json_decode((string) $response->getBody())->access_token;
|
||||||
|
|
||||||
|
// Запись статуса
|
||||||
|
$this->status = 'direct authenticated';
|
||||||
|
} else if ($this->status === 'direct authenticated') {
|
||||||
|
// Аккаунт аутентифицирован через прямую аутентификацию
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован');
|
||||||
|
} else if ($this->status === 'authenticated') {
|
||||||
|
// Аккаунт уже аутентифицирован обычным методом
|
||||||
|
|
||||||
|
throw new Exception('Аккаунт уже аутентифицирован обычным методом и не может использовать прямую аутентификацию');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function browser(): browser
|
||||||
|
{
|
||||||
|
return $this->browser = new browser([
|
||||||
|
'verify' => $this->ssl,
|
||||||
|
'cookies' => (new FileCookieJar($this->path . DIRECTORY_SEPARATOR . 'cookie.txt'))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xpath(string $html, string $query): ?object
|
||||||
|
{
|
||||||
|
// DOM
|
||||||
|
$dom = new DOMDocument;
|
||||||
|
@$dom->loadHTML($html);
|
||||||
|
|
||||||
|
// XPATH 1.0
|
||||||
|
$xpath = new DOMXPath($dom);
|
||||||
|
return $xpath->query($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка запроса на наличие ошибок присланных ВКонтакте
|
||||||
|
*
|
||||||
|
* Выбросит исключение, если найдена ошибка
|
||||||
|
*
|
||||||
|
* @param string $response Ответ сервера ВКонтакте
|
||||||
|
*
|
||||||
|
* @return string|null Ответ сервера ВКонтакте (без изменений)
|
||||||
|
*/
|
||||||
|
private function check(string $response): ?string
|
||||||
|
{
|
||||||
|
// Декодирование ответа из JSON (инициализация)
|
||||||
|
$json = json_decode($response);
|
||||||
|
|
||||||
|
if (json_last_error() === JSON_ERROR_NONE) {
|
||||||
|
// Данные успешно декодировались
|
||||||
|
|
||||||
|
if (isset($json->error)) {
|
||||||
|
// Найдены ошибки
|
||||||
|
|
||||||
|
// Если есть ошибки
|
||||||
|
throw new Exception('ВКонтакте: "' . ($json->error['error_msg'] ?? $json->error_description) . '"', $json->error['error_code'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертация разрешений
|
||||||
|
*
|
||||||
|
* Из строки в битовую маску и наоборот
|
||||||
|
*
|
||||||
|
* @see https://vk.com/dev/permissions Разрешения
|
||||||
|
*
|
||||||
|
* @param int|string $scope Разрешения
|
||||||
|
* @param bool $string Вернуть в виде строки, иначе в виде битовой маски
|
||||||
|
*
|
||||||
|
* @return int|string Результат конвертации или изначальные значения, при неправильной настройке
|
||||||
|
*/
|
||||||
|
private static function scope(int|string $scope, bool $string = false): int|string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (is_string($scope)) {
|
||||||
|
// Разрешения переданы в виде строки
|
||||||
|
|
||||||
|
if ($string) {
|
||||||
|
// Запрос на конвертацию в строку
|
||||||
|
|
||||||
|
return $scope;
|
||||||
|
} else {
|
||||||
|
// Запрос на конвертацию в битовую маску (подразумевается)
|
||||||
|
|
||||||
|
// Инициализация буфера для разрешений
|
||||||
|
$buffer = 0;
|
||||||
|
|
||||||
|
// Удаление пробелов
|
||||||
|
$scope = str_replace(' ', '', $scope);
|
||||||
|
|
||||||
|
// Получение параметров
|
||||||
|
$scope = explode(',', $scope);
|
||||||
|
|
||||||
|
foreach ($scope as $parameter) {
|
||||||
|
// Перебор параметров
|
||||||
|
|
||||||
|
// Генерация битовой маски
|
||||||
|
$buffer += match ($parameter) {
|
||||||
|
'notify' => 1,
|
||||||
|
'friends' => 2,
|
||||||
|
'photos' => 4,
|
||||||
|
'audio' => 8,
|
||||||
|
'video' => 16,
|
||||||
|
'stories' => 64,
|
||||||
|
'pages' => 128,
|
||||||
|
'link' => 256,
|
||||||
|
'status' => 1024,
|
||||||
|
'notes' => 2048,
|
||||||
|
'messages' => 4096,
|
||||||
|
'wall' => 8192,
|
||||||
|
'ads' => 32768,
|
||||||
|
'offline' => 65536,
|
||||||
|
'docs' => 131072,
|
||||||
|
'groups' => 262144,
|
||||||
|
'notifications' => 524288,
|
||||||
|
'stats' => 1048576,
|
||||||
|
'email' => 4194304,
|
||||||
|
'market' => 134217728,
|
||||||
|
default => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Разрешения переданы в виде битовой маски (подразумевается)
|
||||||
|
|
||||||
|
if ($string) {
|
||||||
|
// Запрос на конвертацию в строку
|
||||||
|
|
||||||
|
// Инициализация буфера для разрешений
|
||||||
|
$buffer = '';
|
||||||
|
|
||||||
|
// Инициализация проверочного флага
|
||||||
|
$scan = 1;
|
||||||
|
|
||||||
|
while ($scope >= $scan) {
|
||||||
|
// Перебор битовой маски
|
||||||
|
|
||||||
|
if ($scope & $scan) {
|
||||||
|
// Флаг найден в маске
|
||||||
|
|
||||||
|
// Генерация строки
|
||||||
|
$buffer .= match ($scan) {
|
||||||
|
1 => 'notify,',
|
||||||
|
2 => 'friends,',
|
||||||
|
4 => 'photos,',
|
||||||
|
8 => 'audio,',
|
||||||
|
16 => 'video,',
|
||||||
|
64 => 'stories,',
|
||||||
|
128 => 'pages,',
|
||||||
|
256 => 'link,',
|
||||||
|
1024 => 'status,',
|
||||||
|
2048 => 'notes,',
|
||||||
|
4096 => 'messages,',
|
||||||
|
8192 => 'wall,',
|
||||||
|
32768 => 'ads,',
|
||||||
|
65536 => 'offline,',
|
||||||
|
131072 => 'docs,',
|
||||||
|
262144 => 'groups,',
|
||||||
|
524288 => 'notifications,',
|
||||||
|
1048576 => 'stats,',
|
||||||
|
4194304 => 'email,',
|
||||||
|
134217728 => 'market,',
|
||||||
|
default => ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сдвиг проверочного флага
|
||||||
|
$scan <<= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезание конца (постобработка)
|
||||||
|
$buffer = trim(trim($buffer, ' '), ',');
|
||||||
|
|
||||||
|
return $buffer;
|
||||||
|
} else {
|
||||||
|
// Запрос на конвертацию в битовую маску (подразумевается)
|
||||||
|
|
||||||
|
return $scope;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $buffer;
|
||||||
|
} catch (Exception $e) {
|
||||||
|
throw new Exception('Неизвестная ошибка при инициализации разрешений', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запись
|
||||||
|
*
|
||||||
|
* @param mixed $name Название
|
||||||
|
* @param mixed $value Значение
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function __set($name, $value): void
|
||||||
|
{
|
||||||
|
if ($name === 'id') {
|
||||||
|
throw new Exception('Запрещено инициализировать идентификатор');
|
||||||
|
} else if ($name === 'name' || $name === 'login' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
|
||||||
|
throw new Exception('Запрещено инициализировать псевдоним');
|
||||||
|
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
|
||||||
|
throw new Exception('Запрещено инициализировать пароль');
|
||||||
|
} else if ($name === 'key' || $name === 'token') {
|
||||||
|
$this->key = $value;
|
||||||
|
} else if ($name === 'browser') {
|
||||||
|
throw new Exception('Запрещено инициализировать браузер');
|
||||||
|
} else if ($name === 'path') {
|
||||||
|
$this->path = $value . DIRECTORY_SEPARATOR . $this->id;
|
||||||
|
|
||||||
|
// Проверка и создание директории
|
||||||
|
if (!file_exists($this->path)) {
|
||||||
|
mkdir($this->path, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реинициализация браузера с новым значением
|
||||||
|
$this->browser();
|
||||||
|
} else if ($name === 'ssl') {
|
||||||
|
$this->ssl = $value;
|
||||||
|
// Реинициализация браузера с новым значением
|
||||||
|
$this->browser();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение
|
||||||
|
*
|
||||||
|
* @param mixed $name Название
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function __get($name)
|
||||||
|
{
|
||||||
|
if ($name === 'id') {
|
||||||
|
return $this->id;
|
||||||
|
} else if ($name === 'name' || $name === 'login' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
|
||||||
|
return $this->name;
|
||||||
|
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
|
||||||
|
return $this->password;
|
||||||
|
} else if ($name === 'key') {
|
||||||
|
return $this->key;
|
||||||
|
} else if ($name === 'browser') {
|
||||||
|
return $this->browser;
|
||||||
|
} else if ($name === 'path') {
|
||||||
|
return $this->path;
|
||||||
|
} else if ($name === 'ssl') {
|
||||||
|
return $this->ssl ?? $this->ssl = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Проверка на инициализированность
|
||||||
|
*
|
||||||
|
* @param mixed $name Название
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function __isset($name)
|
||||||
|
{
|
||||||
|
if ($name === 'id') {
|
||||||
|
return isset($this->id);
|
||||||
|
} else if ($name === 'name' || $name === 'login' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
|
||||||
|
return isset($this->name);
|
||||||
|
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
|
||||||
|
return isset($this->password);
|
||||||
|
} else if ($name === 'key') {
|
||||||
|
return isset($this->key);
|
||||||
|
} else if ($name === 'browser') {
|
||||||
|
return isset($this->browser);
|
||||||
|
} else if ($name === 'path') {
|
||||||
|
return isset($this->path);
|
||||||
|
} else if ($name === 'ssl') {
|
||||||
|
return isset($this->ssl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление
|
||||||
|
*
|
||||||
|
* @param mixed $name Название
|
||||||
|
*
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function __unset($name)
|
||||||
|
{
|
||||||
|
if ($name === 'id') {
|
||||||
|
throw new Exception('Запрещено деинициализировать идентификатор');
|
||||||
|
} else if ($name === 'name' || $name === 'login' || $name === 'email' || $name === 'phone' || $name === 'number' || $name === 'nick' || $name === 'nickname') {
|
||||||
|
throw new Exception('Запрещено деинициализировать псевдоним');
|
||||||
|
} else if ($name === 'password' || $name === 'pswd' || $name === 'pass') {
|
||||||
|
throw new Exception('Запрещено деинициализировать пароль');
|
||||||
|
} else if ($name === 'key') {
|
||||||
|
unset($this->key);
|
||||||
|
} else if ($name === 'browser') {
|
||||||
|
unset($this->browser);
|
||||||
|
} else if ($name === 'path') {
|
||||||
|
unset($this->path);
|
||||||
|
} else if ($name === 'ssl') {
|
||||||
|
unset($this->ssl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
/settings.php
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace mirzaev\accounts\tests;
|
||||||
|
|
||||||
|
trait settings
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var int $id Идентификатор
|
||||||
|
*/
|
||||||
|
protected int $id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $login Входной [псевдоним]
|
||||||
|
*/
|
||||||
|
protected string $login = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $password Пароль
|
||||||
|
*/
|
||||||
|
protected string $password = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var int $project_id Идентификатор приложения
|
||||||
|
*/
|
||||||
|
protected int $project_id = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $project_key Ключ приложения
|
||||||
|
*/
|
||||||
|
protected string $project_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string $project_service_key Сервисный ключ приложения
|
||||||
|
*/
|
||||||
|
protected string $project_service_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool $ssl SSL-протокол
|
||||||
|
*/
|
||||||
|
protected bool $ssl = true;
|
||||||
|
}
|
|
@ -0,0 +1,246 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace mirzaev\accounts\tests;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
use mirzaev\accounts\vk as account;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client as browser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox ВКонтакте-аккаунт
|
||||||
|
*/
|
||||||
|
final class vkTest extends TestCase
|
||||||
|
{
|
||||||
|
use settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Аккаунт
|
||||||
|
*/
|
||||||
|
private account $account;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Инициализация теста
|
||||||
|
*/
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
if (empty($this->id)) {
|
||||||
|
$this->markTestSkipped('Не установлен идентификатор');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация аккаунта
|
||||||
|
$this->account = new account($this->id, __DIR__ . DIRECTORY_SEPARATOR . 'accounts');
|
||||||
|
|
||||||
|
// Инициализация свойства SSL-протокола
|
||||||
|
$this->account->ssl = $this->ssl;
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertInstanceOf(account::class, $this->account, 'Не удалось создать аккаунт');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Деинициализация теста
|
||||||
|
*/
|
||||||
|
public function tearDown(): void
|
||||||
|
{
|
||||||
|
// Деаутентификация
|
||||||
|
$this->account->deauth();
|
||||||
|
|
||||||
|
$path = $this->account->path;
|
||||||
|
|
||||||
|
// Удаление аккаунта
|
||||||
|
unset($this->account);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertNull($this->account ?? null, 'Не удалось удалить аккаунт');
|
||||||
|
|
||||||
|
// Удаление файлов
|
||||||
|
unlink($path . DIRECTORY_SEPARATOR . 'cookie.txt');
|
||||||
|
rmdir($path);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertFileDoesNotExist($path, 'Не удалось удалить директорию для тестов');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Аутентификация (базовая) в мобильном режиме
|
||||||
|
*/
|
||||||
|
public function testVkAuthBasicModeMobile(): void
|
||||||
|
{
|
||||||
|
if (empty($this->login)) {
|
||||||
|
$this->markTestSkipped('Не установлен логин');
|
||||||
|
} else if (empty($this->password)) {
|
||||||
|
$this->markTestSkipped('Не установлен пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подождать во избежание блокировки
|
||||||
|
sleep(3);
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$this->account->auth($this->login, $this->password);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Реаутентификация (базовая) в мобильном режиме
|
||||||
|
*/
|
||||||
|
public function testVkReauthBasicModeMobile(): void
|
||||||
|
{
|
||||||
|
if (empty($this->login)) {
|
||||||
|
$this->markTestSkipped('Не установлен логин');
|
||||||
|
} else if (empty($this->password)) {
|
||||||
|
$this->markTestSkipped('Не установлен пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подождать во избежание блокировки
|
||||||
|
sleep(3);
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$this->account->auth($this->login, $this->password);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
|
||||||
|
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('Повторная аутентификация запрещена');
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$this->account->auth($this->login, $this->password);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Аутентификация (базовая) в мобильном режиме с неправильным логином
|
||||||
|
*
|
||||||
|
* Тест может завериться неудачей, если ВКонтакте выдаст блокировку (надо повторить тест позже)
|
||||||
|
*/
|
||||||
|
public function testVkAuthBasicModeMobileWhenLoginIncorrect(): void
|
||||||
|
{
|
||||||
|
// Подождать во избежание блокировки
|
||||||
|
sleep(3);
|
||||||
|
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('ВКонтакте: "Пожалуйста, проверьте правильность введённых данных."');
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$this->account->auth('admin' . rand(0, 1000) . '@hood.su', (string) rand(0, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Аутентификация (базовая) в мобильном режиме c генерацией ключа
|
||||||
|
*/
|
||||||
|
public function testVkAuthBasicModeMobileWithKeyGeneration(): void
|
||||||
|
{
|
||||||
|
if (empty($this->login)) {
|
||||||
|
$this->markTestSkipped('Не установлен логин');
|
||||||
|
} else if (empty($this->password)) {
|
||||||
|
$this->markTestSkipped('Не установлен пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подождать во избежание блокировки
|
||||||
|
sleep(3);
|
||||||
|
|
||||||
|
// Аутентификация
|
||||||
|
$this->account->auth($this->login, $this->password);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertStringNotContainsString('Страница удалена либо ещё не создана.', (string) $this->account->browser->request('GET', 'https://m.vk.com/id0')->getBody(), 'Не удалось аутентифицироваться');
|
||||||
|
|
||||||
|
// Генерация ключа
|
||||||
|
$this->account->key($this->project_id);
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertNotNull($this->account->key(), 'Не удалось сгенерировать ключ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись идентификатора
|
||||||
|
*/
|
||||||
|
public function testSetId(): void
|
||||||
|
{
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('Запрещено инициализировать идентификатор');
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->id = rand(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись входного
|
||||||
|
*/
|
||||||
|
public function testSetLogin(): void
|
||||||
|
{
|
||||||
|
if (empty($this->login)) {
|
||||||
|
$this->markTestSkipped('Не установлен логин');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('Запрещено инициализировать входной');
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->login = $this->login;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись пароля
|
||||||
|
*/
|
||||||
|
public function testSetPassword(): void
|
||||||
|
{
|
||||||
|
if (empty($this->password)) {
|
||||||
|
$this->markTestSkipped('Не установлен пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('Запрещено инициализировать пароль');
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->password = $this->password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись браузера
|
||||||
|
*/
|
||||||
|
public function testSetBrowser(): void
|
||||||
|
{
|
||||||
|
// Проверка выброса исключения
|
||||||
|
$this->expectExceptionMessage('Запрещено инициализировать браузер');
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->browser = new browser();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись корневой директории
|
||||||
|
*/
|
||||||
|
public function testSetPath(): void
|
||||||
|
{
|
||||||
|
// Изначальное значение
|
||||||
|
$old = $this->account->path;
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->path = __DIR__ . DIRECTORY_SEPARATOR . 'accounts' . DIRECTORY_SEPARATOR . 'test_directory';
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertNotSame($this->account->path, $old, 'Не удалось записать корневую директорию');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @testdox Запись SSL
|
||||||
|
*/
|
||||||
|
public function testSetSsl(): void
|
||||||
|
{
|
||||||
|
// Изначальное значение
|
||||||
|
$old = $this->account->ssl;
|
||||||
|
|
||||||
|
// Запись
|
||||||
|
$this->account->ssl = !$old;
|
||||||
|
|
||||||
|
// Проверка
|
||||||
|
$this->assertNotSame($this->account->ssl, $old, 'Не удалось записать SSL');
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue