diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index aadb445c..db0752e4 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,5 @@ -.DS_Store -*.devhelper -xenforo/library/bdApi/DevHelper/Generated/* -xenforo/library/bdApi/DevHelper/XFCP/* -xenforo_consumer/library/bdApiConsumer/DevHelper/Generated/* - -xenforo/library/bdApi/FileSums.php -xenforo_consumer/library/bdApiConsumer/FileSums.php -xenforo/js/bdApi/*.min.js -google-services.json -xenforo_consumer/library/bdApiConsumer/DevHelper/XFCP -xenforo_consumer/js/bdApiConsumer/*.js +/DevHelper/autogen.json +/_build/ +/_data/ +/_releases/ +/vendor/ \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..8a9d79f3 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +variables: + ADDONS_PATH: /var/www/html/src/addons + MYSQL_DATABASE: xenforo2 + MYSQL_ROOT_PASSWORD: root + +build: + artifacts: + expire_in: 1 week + paths: + - _releases + image: registry.gitlab.com/xfrocks/xenforo2/master-devhelper + script: + - mysqlrestore.sh + - composer install --no-dev + - mkdir $ADDONS_PATH/Xfrocks + - cp -R . $ADDONS_PATH/Xfrocks/Api + - cd /var/www/html + - php cmd.php xf-addon:install Xfrocks/Api + - php cmd.php devhelper:autogen Xfrocks/Api + - php cmd.php xf-addon:build-release Xfrocks/Api + - cp -R $ADDONS_PATH/Xfrocks/Api/_releases $CI_PROJECT_DIR/_releases + services: + - mysql:5.7.23 + stage: build + +test: + image: registry.gitlab.com/xfrocks/xenforo2/master + script: + - mysqlrestore.sh + - unzip -qq _releases/*.zip 2>/dev/null || true + - cp -R upload/* /var/www/html + - cd /var/www/html + - php cmd.php xf-addon:install Xfrocks/Api + - php cmd.php xfrocks-api:pre-test + - apache2ctl start + - cd $CI_PROJECT_DIR/_files/tests + - composer install + - composer test + services: + - mysql:5.7.23 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 36970ef3..00000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "xenforo/library/bdApi/Lib/oauth2-server-php"] - path = xenforo/library/bdApi/Lib/oauth2-server-php - url = https://github.com/bshaffer/oauth2-server-php.git - diff --git a/Admin/Controller/AuthCode.php b/Admin/Controller/AuthCode.php new file mode 100644 index 00000000..8c54b41e --- /dev/null +++ b/Admin/Controller/AuthCode.php @@ -0,0 +1,35 @@ +getEntityClientAndUser($entity); + } + + public function getEntityHint($entity) + { + /** @var EntityAuthCode $authCode */ + $authCode = $entity; + return $authCode->scope; + } + + protected function getShortName() + { + return 'Xfrocks\Api:AuthCode'; + } + + protected function getPrefixForPhrases() + { + return 'bdapi_auth_code'; + } + + protected function getRoutePrefix() + { + return 'api-auth-codes'; + } +} diff --git a/Admin/Controller/Client.php b/Admin/Controller/Client.php new file mode 100644 index 00000000..7d6e7d41 --- /dev/null +++ b/Admin/Controller/Client.php @@ -0,0 +1,43 @@ +User; + return $user !== null ? $user->username : ''; + } + + public function getEntityHint($entity) + { + /** @var EntityClient $client */ + $client = $entity; + return $client->redirect_uri; + } + + protected function getShortName() + { + return 'Xfrocks\Api:Client'; + } + + protected function getPrefixForPhrases() + { + return 'bdapi_client'; + } + + protected function getRoutePrefix() + { + return 'api-clients'; + } + + protected function supportsAdding() + { + return false; + } +} diff --git a/Admin/Controller/Entity.php b/Admin/Controller/Entity.php new file mode 100644 index 00000000..d0677be3 --- /dev/null +++ b/Admin/Controller/Entity.php @@ -0,0 +1,48 @@ +getRelation('Client'); + if ($client === null) { + return ''; + } + + /** @var \XF\Entity\User|null $user */ + $user = $entity->getRelation('User'); + return $user !== null ? sprintf('%s / %s', $client->name, $user->username) : $client->name; + } + + protected function getPrefixForClasses() + { + return 'Xfrocks\Api'; + } + + protected function getPrefixForTemplates() + { + return 'bdapi'; + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @return void + * @throws \XF\Mvc\Reply\Exception + */ + protected function preDispatchType($action, ParameterBag $params) + { + parent::preDispatchType($action, $params); + + $this->assertAdminPermission('bdApi'); + } +} diff --git a/Admin/Controller/Log.php b/Admin/Controller/Log.php new file mode 100644 index 00000000..34268858 --- /dev/null +++ b/Admin/Controller/Log.php @@ -0,0 +1,61 @@ +client_id, $entity->ip_address); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + public function actionView(ParameterBag $paramBag) + { + $log = $this->assertRecordExists('Xfrocks\Api:Log', $paramBag->log_id); + + return $this->view('Xfrocks\Api:Logs\View', 'bdapi_log_view', [ + 'log' => $log + ]); + } +} diff --git a/Admin/Controller/RefreshToken.php b/Admin/Controller/RefreshToken.php new file mode 100644 index 00000000..973a96ee --- /dev/null +++ b/Admin/Controller/RefreshToken.php @@ -0,0 +1,35 @@ +getEntityClientAndUser($entity); + } + + public function getEntityHint($entity) + { + /** @var EntityRefreshToken $refreshToken */ + $refreshToken = $entity; + return $refreshToken->scope; + } + + protected function getShortName() + { + return 'Xfrocks\Api:RefreshToken'; + } + + protected function getPrefixForPhrases() + { + return 'bdapi_refresh_token'; + } + + protected function getRoutePrefix() + { + return 'api-refresh-tokens'; + } +} diff --git a/Admin/Controller/Subscription.php b/Admin/Controller/Subscription.php new file mode 100644 index 00000000..401690a5 --- /dev/null +++ b/Admin/Controller/Subscription.php @@ -0,0 +1,37 @@ +getRelation('Client'); + return $client !== null ? $client->name : ''; + } + + public function getEntityHint($entity) + { + /** @var EntitySubscription $subscription */ + $subscription = $entity; + return $subscription->callback; + } + + protected function getShortName() + { + return 'Xfrocks\Api:Subscription'; + } + + protected function getPrefixForPhrases() + { + return 'bdapi_subscription'; + } + + protected function getRoutePrefix() + { + return 'api-subscriptions'; + } +} diff --git a/Admin/Controller/Token.php b/Admin/Controller/Token.php new file mode 100644 index 00000000..54cb59d8 --- /dev/null +++ b/Admin/Controller/Token.php @@ -0,0 +1,35 @@ +getEntityClientAndUser($entity); + } + + public function getEntityHint($entity) + { + /** @var EntityToken $token */ + $token = $entity; + return $token->scope; + } + + protected function getShortName() + { + return 'Xfrocks\Api:Token'; + } + + protected function getPrefixForPhrases() + { + return 'bdapi_token'; + } + + protected function getRoutePrefix() + { + return 'api-tokens'; + } +} diff --git a/App.php b/App.php new file mode 100644 index 00000000..bd5700fa --- /dev/null +++ b/App.php @@ -0,0 +1,148 @@ +container; + + // initialize this early for high availability + $container['api.server'] = Listener::apiServer($this); + + $container['app.classType'] = 'Api'; + + $container->extend('extension', function (\XF\Extension $extension) { + $extension->addListener('dispatcher_match', ['Xfrocks\Api\Listener', 'apiOnlyDispatcherMatch']); + + return $extension; + }); + + $container->extend('extension.classExtensions', function (array $classExtensions) { + $xfClasses = [ + 'BbCode\Renderer\Html', + 'ControllerPlugin\Error', + 'Entity\User', + 'Image\Gd', + 'Image\Imagick', + 'Mvc\Dispatcher', + 'Mvc\Renderer\Json', + 'Session\Session', + 'Template\Templater', + ]; + + foreach ($xfClasses as $xfClass) { + $extendBase = 'XF\\' . $xfClass; + if (!isset($classExtensions[$extendBase])) { + $classExtensions[$extendBase] = []; + } + + $extendClass = 'Xfrocks\Api\\' . 'XF\\ApiOnly\\' . $xfClass; + $classExtensions[$extendBase][] = $extendClass; + } + + return $classExtensions; + }); + + $container['request'] = function (\XF\Container $c) { + /** @var Server $apiServer */ + $apiServer = $this->container('api.server'); + /** @var \Symfony\Component\HttpFoundation\Request $apiRequest */ + $apiRequest = $apiServer->container('request'); + + $request = new \XF\Http\Request( + $c['inputFilterer'], + $apiRequest->request->all() + $apiRequest->query->all(), + $_FILES, + [] + ); + + return $request; + }; + + $container->extend('request.paths', function (array $paths) { + // move base directory up one level for URL building + // TODO: make the change directly at XF\Http\Request::getBaseUrl + $apiDirNameRegEx = '#' . preg_quote(Listener::$apiDirName, '#') . '/$#'; + $paths['full'] = preg_replace($apiDirNameRegEx, '', $paths['full']); + $paths['base'] = preg_replace($apiDirNameRegEx, '', $paths['base']); + + return $paths; + }); + + $container->extend('request.pather', function ($pather) { + return function ($url, $modifier = 'full') use ($pather) { + // always use canonical/full URL in api context + if ($modifier !== 'canonical') { + $modifier = 'full'; + } + + return $pather($url, $modifier); + }; + }); + } + + /** + * @param \XF\Session\Session $session + * @return void + */ + protected function onSessionCreation(\XF\Session\Session $session) + { + /** @var Server $apiServer */ + $apiServer = $this->container('api.server'); + $accessToken = $apiServer->parseRequest(); + + /** @var \Xfrocks\Api\XF\ApiOnly\Session\Session $apiSession */ + $apiSession = $session; + $apiSession->setToken($accessToken !== null ? $accessToken->getXfToken() : null); + } + + /** + * @return void + */ + protected function updateModeratorCaches() + { + // no op + } + + /** + * @return void + */ + protected function updateUserCaches() + { + // no op + } +} diff --git a/Cli/Command/PreTest.php b/Cli/Command/PreTest.php new file mode 100644 index 00000000..68886431 --- /dev/null +++ b/Cli/Command/PreTest.php @@ -0,0 +1,349 @@ +em()->create('XF:Node'); + $node->display_in_list = false; + $node->node_type_id = 'Forum'; + $node->title = sprintf('%s-%d', $this->prefix, \XF::$time); + + /** @var Forum $forum */ + $forum = $node->getDataRelationOrDefault(); + $node->addCascadedSave($forum); + + $node->save(); + + $data['forum'] = [ + 'node_id' => $node->node_id, + 'version_id' => self::VERSION_ID + ]; + } + + return $data['forum']; + } + + /** + * @param array $data + * @return array + */ + public function createUsers(array &$data) + { + $app = \XF::app(); + if (!isset($data['users'])) { + $data['users'] = []; + } + + for ($i = 0; $i < $this->users; $i++) { + if (isset($data['users'][$i])) { + continue; + } + + $username = sprintf('%s-%d-%d', $this->prefix, \XF::$time, $i + 1); + $password = Random::getRandomString(32); + + /** @var \XF\Service\User\Registration $registration */ + $registration = $app->service('XF:User\Registration'); + $user = $registration->getUser(); + $user->setOption('admin_edit', true); + $registration->setFromInput(['username' => $username]); + $registration->setPassword($password, '', false); + $registration->skipEmailConfirmation(); + $registration->save(); + + $data['users'][$i] = [ + 'user_id' => $user->user_id, + 'username' => $username, + 'password' => $password, + 'version_id' => self::VERSION_ID + ]; + + if ($i === 3) { + /** @var \XF\Service\UpdatePermissions $permissionUpdater */ + $permissionUpdater = $app->service('XF:UpdatePermissions'); + $permissionUpdater->setUser($user)->setGlobal(); + $permissionUpdater->updatePermissions([ + 'general' => [ + 'bypassFloodCheck' => 'allow' + ] + ]); + } elseif ($i === 4) { + /** @var TfaProvider $tfaProvider */ + $tfaProvider = $app->em()->find('XF:TfaProvider', 'totp'); + $handler = $tfaProvider->getHandler(); + if ($handler !== null) { + $initialData = $handler->generateInitialData($user); + $data['users'][$i]['tfa_secret'] = $initialData['secret']; + + /** @var \XF\Repository\Tfa $tfaRepo */ + $tfaRepo = $app->repository('XF:Tfa'); + $tfaRepo->enableUserTfaProvider($user, $tfaProvider, $initialData); + } + } + } + + return $data['users']; + } + + /** + * @param array $data + * @return mixed + * @throws \Exception + * @throws PrintableException + */ + public function createThreads(array &$data) + { + if (!isset($data['threads'])) { + $data['threads'] = []; + } + + $app = \XF::app(); + /** @var Forum $forum */ + $forum = $app->em()->find('XF:Forum', $data['forum']['node_id']); + /** @var User $user */ + $user = $app->em()->find('XF:User', $data['users'][0]['user_id']); + + for ($i = 0; $i < $this->threads; $i++) { + if (isset($data['threads'][$i])) { + continue; + } + + /** @var \XF\Service\Thread\Creator $creator */ + $creator = \XF::asVisitor($user, function () use ($forum, $app) { + return $app->service('XF:Thread\Creator', $forum); + }); + + $creator->setContent( + sprintf('%s-%d-%d', $this->prefix, \XF::$time, $i + 1), + str_repeat(__METHOD__ . ' ', 10) + ); + + if (!$creator->validate($errors)) { + throw new PrintableException($errors); + } + + $thread = $creator->save(); + $data['threads'][$i] = [ + 'thread_id' => $thread->thread_id, + 'title' => $thread->title, + 'node_id' => $thread->node_id, + 'version_id' => self::VERSION_ID + ]; + } + + return $data['threads']; + } + + /** + * @param array $data + * @return mixed + * @throws \Exception + * @throws PrintableException + */ + public function createPosts(array &$data) + { + if (!isset($data['posts'])) { + $data['posts'] = []; + } + + $app = \XF::app(); + /** @var Thread $thread */ + $thread = $app->em()->find('XF:Thread', $data['threads'][0]['thread_id']); + /** @var User $user */ + $user = $app->em()->find('XF:User', $data['users'][0]['user_id']); + + for ($i = 0; $i < $this->posts; $i++) { + if (isset($data['posts'][$i])) { + continue; + } + + /** @var Replier $replier */ + $replier = \XF::asVisitor($user, function () use ($app, $thread) { + return $app->service('XF:Thread\Replier', $thread); + }); + + $replier->setMessage(str_repeat(__METHOD__ . ' ', 10)); + if (!$replier->validate($errors)) { + throw new PrintableException($errors); + } + + $post = $replier->save(); + $data['posts'][$i] = [ + 'post_id' => $post->post_id, + 'thread_id' => $thread->thread_id, + 'version_id' => self::VERSION_ID + ]; + } + + return $data['posts']; + } + + /** + * @param array $userData + * @param array $data + * @return mixed + * @throws PrintableException + */ + public function createApiClient(array $userData, array &$data) + { + if (!isset($data['apiClient'])) { + $app = \XF::app(); + + /** @var Client $client */ + $client = $app->em()->create('Xfrocks\Api:Client'); + $client->name = $this->prefix; + $client->description = __METHOD__; + $client->redirect_uri = $app->options()->boardUrl; + $client->user_id = $userData['user_id']; + $client->save(); + + $data['apiClient'] = [ + 'client_id' => $client->client_id, + 'client_secret' => $client->client_secret, + 'version_id' => self::VERSION_ID + ]; + } + + return $data['apiClient']; + } + + /** + * @param array $data + * @return void + */ + public function enableSubscriptions(array &$data) + { + $app = \XF::app(); + $options = $app->options(); + /** @var \XF\Repository\Option $optionRepo */ + $optionRepo = $app->repository('XF:Option'); + $data['subscriptions'] = ['options' => []]; + + foreach ([ + 'bdApi_subscriptionColumnThreadPost' => 'xf_thread', + 'bdApi_subscriptionColumnUser' => 'xf_user_option', + 'bdApi_subscriptionColumnUserNotification' => 'xf_user_option', + ] as $optionName => $tableName) { + $columnName = strval($options->offsetGet($optionName)); + if (strpos($columnName, $optionName) !== 0) { + $sm = \XF::db()->getSchemaManager(); + + $columnName = sprintf('%s_%d', $optionName, \XF::$time); + $sm->alterTable($tableName, function (\XF\Db\Schema\Alter $table) use ($columnName) { + $table->addColumn($columnName, 'MEDIUMBLOB')->nullable(true); + }); + + $optionRepo->updateOption($optionName, $columnName); + $options[$optionName] = $columnName; + } + + $data['subscriptions']['options'][$optionName] = $columnName; + } + + foreach ([ + 'bdApi_subscriptionThreadPost', + 'bdApi_subscriptionUser', + 'bdApi_subscriptionUserNotification', + + // for testing api tags + 'enableTagging' + ] as $optionName) { + $optionRepo->updateOption($optionName, true); + } + } + + /** + * @return void + */ + protected function configure() + { + $this + ->setName('xfrocks-api:pre-test') + ->setDescription('Prepare environment for API testings'); + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int|null + * @throws PrintableException + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + if (!\XF::$debugMode) { + throw new \LogicException('XenForo must be in debug mode for testing'); + } + + $jsonPath = "/tmp/{$this->prefix}.json"; + + $data = []; + if (file_exists($jsonPath)) { + $json = file_get_contents($jsonPath); + if (is_string($json)) { + $data = @json_decode($json, true); + if (!is_array($data)) { + $data = []; + } + } + } + + $this->enableSubscriptions($data); + + $this->createForum($data); + $this->createUsers($data); + $this->createThreads($data); + $this->createPosts($data); + + $this->createApiClient($data['users'][0], $data); + + file_put_contents($jsonPath, json_encode($data, JSON_PRETTY_PRINT)); + $output->writeln("Written test data to {$jsonPath}"); + + return 0; + } +} diff --git a/Controller/AbstractController.php b/Controller/AbstractController.php new file mode 100644 index 00000000..7e7602a6 --- /dev/null +++ b/Controller/AbstractController.php @@ -0,0 +1,415 @@ + $params->get('action'), + 'class' => get_class($this), + ]; + + return $this->api($data); + } + + /** + * @param array $data + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function api(array $data) + { + return new \Xfrocks\Api\Mvc\Reply\Api($data); + } + + /** + * @param string|null $scope + * @return void + * @throws \XF\Mvc\Reply\Exception + */ + public function assertApiScope($scope) + { + if ($scope === null || strlen($scope) === 0) { + return; + } + + $session = $this->session(); + if (!$session->hasScope($scope)) { + throw $this->errorException(\XF::phrase('do_not_have_permission'), 403); + } + } + + /** + * @param string $linkUrl + * @return void + * @throws Reply\Exception + */ + public function assertCanonicalUrl($linkUrl) + { + $responseType = $this->responseType; + $this->responseType = 'html'; + $exception = null; + + try { + parent::assertCanonicalUrl($linkUrl); + } catch (Reply\Exception $exceptionReply) { + $reply = $exceptionReply->getReply(); + if ($reply instanceof Redirect) { + /** @var Redirect $redirect */ + $redirect = $reply; + $url = $redirect->getUrl(); + if (preg_match('#^https?://.+(https?://.+)$#', $url, $matches) === 1) { + // because we are unable to modify XF\Http\Request::getBaseUrl, + // parent::assertCanonicalUrl will prepend the full base path incorrectly. + // And because we don't want to parse the request params ourselves + // we will take care of the extraneous prefix here + $alteredUrl = $matches[1]; + + if ($alteredUrl === $this->request->getRequestUri()) { + // skip redirecting, if it happens to be the current request URI + $exceptionReply = null; + } else { + $redirect->setUrl($alteredUrl); + } + } + } + + $exception = $exceptionReply; + } catch (\Exception $e) { + $exception = $e; + } + + $this->responseType = $responseType; + if ($exception !== null) { + throw $exception; + } + } + + /** + * @return void + * @throws Reply\Exception + */ + protected function assertValidToken() + { + if ($this->session()->getToken() === null) { + throw $this->exception($this->noPermission()); + } + } + + /** + * @param string $link + * @param mixed $data + * @param array $parameters + * @return string + */ + public function buildApiLink($link, $data = null, array $parameters = []) + { + return $this->app->router(Listener::$routerType)->buildLink($link, $data, $parameters); + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @return void + */ + public function checkCsrfIfNeeded($action, ParameterBag $params) + { + // no op + } + + public function filter($key, $type = null, $default = null) + { + throw new \InvalidArgumentException('AbstractController::params() must be used to parse params.'); + } + + /** + * @param string $type + * @param array|int $whereId + * @param string|null $phraseKey + * @return LazyTransformer + */ + public function findAndTransformLazily($type, $whereId, $phraseKey = null) + { + $finder = $this->finder($type); + $finder->whereId($whereId); + + $sortByList = null; + $isSingle = true; + if (is_array($whereId)) { + $primaryKey = $finder->getStructure()->primaryKey; + if (is_array($primaryKey) && count($primaryKey) === 1) { + $primaryKey = reset($primaryKey); + } + if (!is_array($primaryKey)) { + $isSingle = false; + $sortByList = $whereId; + } else { + // TODO: implement this + throw new \RuntimeException('Compound primary key is not supported'); + } + } + + $lazyTransformer = new LazyTransformer($this); + $lazyTransformer->setFinder($finder); + + if ($sortByList !== null) { + $lazyTransformer->addCallbackFinderPostFetch(function ($entities) use ($sortByList) { + /** @var \XF\Mvc\Entity\ArrayCollection $arrayCollection */ + $arrayCollection = $entities; + $entities = $arrayCollection->sortByList($sortByList); + + return $entities; + }); + } + + $lazyTransformer->addCallbackPostTransform(function ($data) use ($isSingle, $phraseKey) { + if (!$isSingle) { + return $data; + } + + if (count($data) === 1) { + return $data[0]; + } + + if ($phraseKey === null) { + $phraseKey = 'requested_page_not_found'; + } + + throw $this->exception($this->notFound(\XF::phrase($phraseKey))); + }); + + return $lazyTransformer; + } + + /** + * @return Params + */ + public function params() + { + if ($this->apiParams === null) { + $this->apiParams = new Params($this); + } + + return $this->apiParams; + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @return void + * @throws Reply\Exception + */ + public function preDispatch($action, ParameterBag $params) + { + parent::preDispatch($action, $params); + + $this->apiParams = null; + + $addOnId = 'Xfrocks/Api'; + $addOnCache = $this->app->container('addon.cache'); + if (!isset($addOnCache[$addOnId])) { + throw $this->errorException('The API is currently disabled.', 500); + } + if (\XF::$debugMode) { + $addOn = $this->app->addOnManager()->getById($addOnId); + if ($addOn->isJsonVersionNewer()) { + throw $this->errorException('Please update the API add-on.', 500); + } + } + + $scope = $this->getDefaultApiScopeForAction($action); + $this->assertApiScope($scope); + } + + /** + * @return \Xfrocks\Api\XF\ApiOnly\Session\Session + */ + public function session() + { + /** @var \Xfrocks\Api\XF\ApiOnly\Session\Session $session */ + $session = parent::session(); + return $session; + } + + /** + * @param array $data + * @param string $key + * @param Entity $entity + * @return LazyTransformer + */ + public function transformEntityIfNeeded(array &$data, $key, $entity) + { + $lazyTransformer = $this->transformEntityLazily($entity); + $lazyTransformer->addCallbackPreTransform(function ($context) use ($key) { + /** @var TransformContext $context */ + if ($context->selectorShouldExcludeField($key)) { + return null; + } + + return $context->getSubContext($key, null, null); + }); + $data[$key] = $lazyTransformer; + + return $lazyTransformer; + } + + /** + * @param Entity $entity + * @return LazyTransformer + */ + public function transformEntityLazily($entity) + { + $lazyTransformer = new LazyTransformer($this); + $lazyTransformer->setEntity($entity); + return $lazyTransformer; + } + + /** + * @param Finder $finder + * @return LazyTransformer + */ + public function transformFinderLazily($finder) + { + $lazyTransformer = new LazyTransformer($this); + $lazyTransformer->setFinder($finder); + return $lazyTransformer; + } + + /** + * @param mixed $viewClass + * @param mixed $templateName + * @param array $params + * @return Reply\View + */ + public function view($viewClass = '', $templateName = '', array $params = []) + { + if ($viewClass !== '') { + $viewClass = \XF::stringToClass($viewClass, '%s\%s\View\%s', 'Pub'); + } + + return parent::view($viewClass, $templateName, $params); + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @param AbstractReply $reply + * @param mixed $viewState + * @return false + */ + protected function canUpdateSessionActivity($action, ParameterBag $params, AbstractReply &$reply, &$viewState) + { + return false; + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @param AbstractReply $reply + * @return void + */ + public function postDispatch($action, ParameterBag $params, Reply\AbstractReply &$reply) + { + $this->logRequest($reply); + + parent::postDispatch($action, $params, $reply); + } + + /** + * @param AbstractReply $reply + * @return void + */ + protected function logRequest(AbstractReply $reply) + { + $requestMethod = $this->request()->getServer('REQUEST_METHOD'); + $requestUri = $this->request()->getRequestUri(); + + $responseOutput = $this->getControllerResponseOutput($reply, $responseCode); + if ($responseOutput === false) { + return; + } + + $requestData = $this->request()->getInputForLogs(); + + /** @var Log $logRepo */ + $logRepo = $this->repository('Xfrocks\Api:Log'); + $logRepo->logRequest($requestMethod, $requestUri, $requestData, $responseCode, $responseOutput); + } + + /** + * @param AbstractReply|Reply\Exception $reply + * @param int $responseCode + * @return array|false + */ + protected function getControllerResponseOutput($reply, &$responseCode) + { + if ($reply instanceof AbstractReply) { + $responseCode = $reply->getResponseCode(); + } + + if ($reply instanceof Redirect) { + $responseCode = 301; + $responseOutput = [ + 'redirectType' => $reply->getType(), + 'redirectMessage' => $reply->getMessage(), + 'redirectUri' => $reply->getUrl() + ]; + } elseif ($reply instanceof Reply\View) { + $responseOutput = $reply->getParams(); + } elseif ($reply instanceof Reply\Error) { + $responseOutput = ['errors' => $reply->getErrors()]; + } elseif ($reply instanceof Reply\Exception) { + $responseOutput = $this->getControllerResponseOutput($reply->getReply(), $responseCode); + } elseif ($reply instanceof Reply\Message) { + $responseOutput = ['message' => $reply->getMessage()]; + } else { + return false; + } + + return $responseOutput; + } + + /** + * @param string $action + * @return string|null + */ + protected function getDefaultApiScopeForAction($action) + { + if (strpos($action, 'Post') === 0) { + return Server::SCOPE_POST; + } elseif (strpos($action, 'Put') === 0) { + // TODO: separate scope? + return Server::SCOPE_POST; + } elseif (strpos($action, 'Delete') === 0) { + // TODO: separate scope? + return Server::SCOPE_POST; + } elseif ($this->options()->bdApi_restrictAccess) { + return Server::SCOPE_READ; + } + + return null; + } +} diff --git a/Controller/AbstractNode.php b/Controller/AbstractNode.php new file mode 100644 index 00000000..77fe39be --- /dev/null +++ b/Controller/AbstractNode.php @@ -0,0 +1,117 @@ +node_id) { + return $this->actionSingle($params->node_id); + } + + $params = $this + ->params() + ->define('parent_category_id', 'str', 'id of parent category') + ->define('parent_forum_id', 'str', 'id of parent forum') + ->define('parent_node_id', 'str', 'id of parent node') + ->defineOrder([ + 'list' => ['lft', 'asc'] + ]); + + $parentId = $params['parent_category_id']; + if ($parentId === '') { + $parentId = $params['parent_forum_id']; + } + if ($parentId === '') { + $parentId = $params['parent_node_id']; + } + if ($parentId === '') { + $parentId = false; + } else { + $parentId = intval($parentId); + } + + /** @var Node $nodeRepo */ + $nodeRepo = $this->repository('XF:Node'); + $nodeList = $nodeRepo->getNodeList(); + + $nodeIds = []; + /** @var \XF\Entity\Node $nodeItem */ + foreach ($nodeList as $nodeItem) { + if ($parentId !== false && $nodeItem->parent_node_id !== $parentId) { + continue; + } + + if ($nodeItem->node_type_id === $this->getNodeTypeId()) { + $nodeIds[] = $nodeItem->node_id; + } + } + + $nodeTypes = $this->app()->container('nodeTypes'); + $keyNodes = $this->getNamePlural(); + $keyTotal = $this->getNamePlural() . '_total'; + $data = [$keyNodes => [], $keyTotal => 0]; + + if (count($nodeIds) > 0 && isset($nodeTypes[$this->getNodeTypeId()])) { + $entityIdentifier = $nodeTypes[$this->getNodeTypeId()]['entity_identifier']; + + $finder = $this->finder($entityIdentifier) + ->where('node_id', $nodeIds); + + $params->sortFinder($finder); + + $data[$keyTotal] = $finder->total(); + $data[$keyNodes] = $this->transformFinderLazily($finder); + } + + return $this->api($data); + } + + /** + * @param int $nodeId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionSingle($nodeId) + { + $nodeTypes = $this->app()->container('nodeTypes'); + $nodeTypeId = $this->getNodeTypeId(); + + if (!isset($nodeTypes[$nodeTypeId])) { + return $this->noPermission(); + } + + $with = 'Node.Permissions|' . \XF::visitor()->permission_combination_id; + $node = $this->assertRecordExists($nodeTypes[$nodeTypeId]['entity_identifier'], $nodeId, $with); + + $data = [ + $this->getNameSingular() => $this->transformEntityLazily($node) + ]; + + return $this->api($data); + } + + /** + * @return string + */ + abstract protected function getNodeTypeId(); + + /** + * @return string + */ + abstract protected function getNamePlural(); + + /** + * @return string + */ + abstract protected function getNameSingular(); +} diff --git a/Controller/Asset.php b/Controller/Asset.php new file mode 100644 index 00000000..1379521a --- /dev/null +++ b/Controller/Asset.php @@ -0,0 +1,67 @@ +params() + ->define('prefix', 'str', 'JS code prefix'); + + $prefix = preg_replace('/[^a-zA-Z0-9]/', '', $params['prefix']); + + $sdkPath = ''; + if (\XF::$debugMode) { + $devSdkPath = dirname(__DIR__) . '/_files/js/Xfrocks/Api/sdk.js'; + if (file_exists($devSdkPath)) { + $sdkPath = $devSdkPath; + } + } + + if ($sdkPath === '') { + $sdkPath = sprintf( + '%1$s%2$sjs%2$sXfrocks%2$sApi%2$s' . 'sdk.min.js', + \XF::getRootDirectory(), + DIRECTORY_SEPARATOR + ); + } + if (!file_exists($sdkPath)) { + return $this->noPermission(); + } + + $sdk = strval(file_get_contents($sdkPath)); + $sdk = str_replace('{prefix}', $prefix, $sdk); + $sdk = str_replace('{data_uri}', $this->app->router('public')->buildLink('misc/api-data'), $sdk); + $sdk = str_replace('{request_uri}', $this->buildApiLink('index'), $sdk); + + $this->setResponseType('raw'); + return $this->view('Xfrocks\Api\View\Asset\Sdk', '', ['sdk' => $sdk]); + } + + /** + * @param mixed $action + * @return void + */ + public function assertBoardActive($action) + { + // intentionally left empty + } + + /** + * @param mixed $action + * @return void + */ + public function assertViewingPermissions($action) + { + // intentionally left empty + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } +} diff --git a/Controller/Attachment.php b/Controller/Attachment.php new file mode 100644 index 00000000..714272f5 --- /dev/null +++ b/Controller/Attachment.php @@ -0,0 +1,89 @@ +attachment_id) { + return $this->actionSingle($params->attachment_id); + } + + return $this->notFound(); + } + + /** + * @param ParameterBag $pb + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionDeleteIndex(ParameterBag $pb) + { + $params = $this->params() + ->define('hash', 'str'); + + /** @var \XF\Entity\Attachment $attachment */ + $attachment = $this->assertRecordExists('XF:Attachment', $pb->attachment_id); + + $handler = $attachment->getHandler(); + if ($handler === null) { + return $this->noPermission(); + } + + if (strlen($attachment->temp_hash) > 0) { + if ($params['hash'] !== $attachment->temp_hash) { + return $this->noPermission(); + } + } else { + $entity = $handler->getContainerEntity($attachment->content_id); + $context = $handler->getContext($entity); + $error = null; + if (!$handler->canManageAttachments($context, $error)) { + return $this->noPermission($error); + } + } + + $attachment->delete(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param int $attachmentId + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + protected function actionSingle($attachmentId) + { + /** @var \XF\Entity\Attachment|null $attachment */ + $attachment = $this->em()->find('XF:Attachment', $attachmentId); + if ($attachment === null) { + throw $this->exception($this->notFound()); + } + + if ($attachment->temp_hash !== '') { + $hash = $this->filter('hash', 'str'); + if ($attachment->temp_hash !== $hash) { + return $this->noPermission(); + } + } else { + if (!$attachment->canView($error)) { + return $this->noPermission($error); + } + } + + /** @var \XF\ControllerPlugin\Attachment $attachPlugin */ + $attachPlugin = $this->plugin('XF:Attachment'); + + return $attachPlugin->displayAttachment($attachment); + } +} diff --git a/Controller/Batch.php b/Controller/Batch.php new file mode 100644 index 00000000..be63b419 --- /dev/null +++ b/Controller/Batch.php @@ -0,0 +1,114 @@ +request->getInputRaw(); + if ($inputRaw === '' && \XF::$debugMode) { + $inputRaw = $this->request->filter('_xfApiInputRaw', 'str'); + } + $configs = @json_decode($inputRaw, true); + if (!is_array($configs)) { + return $this->error(\XF::phrase('bdapi_slash_batch_requires_json')); + } + + $replies = []; + foreach ($configs as $config) { + if (!is_array($config)) { + continue; + } + $job = $this->buildJobFromConfig($config); + if ($job === null) { + continue; + } + + if (!isset($config['id'])) { + $i = 0; + do { + $id = $i > 0 ? sprintf('%s_%d', $job->getUri(), $i) : $job->getUri(); + $i++; + } while (isset($replies[$id])); + } else { + $id = $config['id']; + } + + $replies[$id] = $job->execute(); + } + + $data = [ + 'jobs' => $this->transformReplies($replies) + ]; + + return $this->api($data); + } + + /** + * @param array $config + * @return BatchJob|null + */ + protected function buildJobFromConfig(array $config) + { + if (!isset($config['uri']) || !is_string($config['uri'])) { + return null; + } + + if (!isset($config['method'])) { + $config['method'] = 'GET'; + } + $config['method'] = strtoupper($config['method']); + + if (!isset($config['params']) || !is_array($config['params'])) { + $config['params'] = []; + } + + /** @var string|false $uriQuery */ + $uriQuery = @parse_url($config['uri'], PHP_URL_QUERY); + if ($uriQuery !== false) { + $uriParams = []; + parse_str($uriQuery, $uriParams); + if (count($uriParams) > 0) { + $config['params'] = array_merge($uriParams, $config['params']); + } + } + + return new BatchJob($this->app, $config['method'], $config['params'], $config['uri']); + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } + + protected function logRequest(AbstractReply $reply) + { + // Does not support log request for this controller + } + + /** + * @param array $replies + * @return array + */ + protected function transformReplies(array $replies) + { + $data = []; + + /** @var Transformer $transformer */ + $transformer = $this->app()->container('api.transformer'); + + foreach ($replies as $jobId => $reply) { + $data[$jobId] = $transformer->transformBatchJobReply($reply); + } + + return $data; + } +} diff --git a/Controller/Category.php b/Controller/Category.php new file mode 100644 index 00000000..1faa1a2d --- /dev/null +++ b/Controller/Category.php @@ -0,0 +1,21 @@ +assertApiScope('conversate'); + $this->assertRegistrationRequired(); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetIndex(ParameterBag $params) + { + if ($params->conversation_id) { + return $this->actionSingle($params->conversation_id); + } + + $params = $this + ->params() + ->definePageNav(); + + $visitor = \XF::visitor(); + /** @var \XF\Repository\Conversation $conversionRepo */ + $conversionRepo = $this->repository('XF:Conversation'); + + $finder = $conversionRepo->findUserConversations($visitor); + $params->limitFinderByPage($finder); + + $total = $finder->total(); + + $conversations = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'conversations' => $conversations, + 'conversations_total' => $total + ]; + + PageNav::addLinksToData($data, $params, $total, 'conversations'); + + return $this->api($data); + } + + /** + * @param int $conversationId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionSingle($conversationId) + { + $conversation = $this->assertViewableConversation($conversationId); + + $data = [ + 'conversation' => $this->transformEntityLazily($conversation) + ]; + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostIndex() + { + $params = $this + ->params() + ->define('conversation_title', 'str', 'title of the new conversation') + ->define('recipients', 'str', 'usernames of recipients of the new conversation') + ->define('message_body', 'str', 'content of the new conversation') + ->defineAttachmentHash(); + + $visitor = \XF::visitor(); + + /** @var \XF\Service\Conversation\Creator $creator */ + $creator = $this->service('XF:Conversation\Creator', $visitor); + $creator->setRecipients($params['recipients']); + $creator->setContent($params['conversation_title'], $params['message_body']); + + $contentData = ['message_id' => 0]; + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($contentData); + + if ($creator->getConversation()->canUploadAndManageAttachments()) { + $creator->setAttachmentHash($tempHash); + } + + $creator->checkForSpam(); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $conversation = $creator->save(); + return $this->actionSingle($conversation->conversation_id); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionDeleteIndex(ParameterBag $params) + { + $conversation = $this->assertViewableConversation($params->conversation_id); + + $recipient = $conversation->Recipients[\XF::visitor()->user_id]; + $recipient->recipient_state = 'deleted'; + $recipient->save(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostAttachments() + { + $this->params() + ->defineFile('file') + ->defineAttachmentHash(); + + $contentData = ['message_id' => 0]; + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($contentData); + + return $attachmentPlugin->doUpload($tempHash, 'conversation_message', $contentData); + } + + /** + * @param int $conversationId + * @param array $extraWith + * @return \XF\Entity\ConversationMaster + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableConversation($conversationId, array $extraWith = []) + { + $extraWith[] = 'Users|' . \XF::visitor()->user_id; + + /** @var \XF\Entity\ConversationMaster $conversation */ + $conversation = $this->assertRecordExists( + 'XF:ConversationMaster', + $conversationId, + $extraWith, + 'requested_conversation_not_found' + ); + + if (!$conversation->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $conversation; + } +} diff --git a/Controller/ConversationMessage.php b/Controller/ConversationMessage.php new file mode 100644 index 00000000..2e7b47a2 --- /dev/null +++ b/Controller/ConversationMessage.php @@ -0,0 +1,384 @@ +message_id) { + return $this->actionSingle($params->message_id); + } + + $orderChoices = [ + 'natural' => ['message_date', 'ASC'], + 'natural_reverse' => ['message_date', 'DESC'] + ]; + + $params = $this + ->params() + ->define('conversation_id', 'uint', 'conversation id to filter') + ->define('before', 'uint', 'date to get older messages') + ->define('after', 'uint', 'date to get newer messages') + ->definePageNav() + ->defineOrder($orderChoices); + + $this->assertRegistrationRequired(); + + /** @var ConversationMaster $conversation */ + $conversation = $this->assertRecordExists('XF:ConversationMaster', $params['conversation_id']); + if (!$conversation->canView($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Repository\ConversationMessage $convoMessageRepo */ + $convoMessageRepo = $this->repository('XF:ConversationMessage'); + + $finder = $convoMessageRepo->findMessagesForConversationView($conversation); + $this->applyMessagesFilters($finder, $params); + + $total = $finder->total(); + + $tc = $this->params()->getTransformContext(); + $tc->onTransformEntitiesCallbacks[] = function ($_, $entities) use ($conversation) { + $maxReadDate = 0; + foreach ($entities as $entity) { + if (!$entity instanceof \XF\Entity\ConversationMessage) { + continue; + } + + $maxReadDate = max($entity->message_date, $maxReadDate); + } + + if ($maxReadDate > 0) { + /** @var \XF\Repository\Conversation $convoRepo */ + $convoRepo = $this->repository('XF:Conversation'); + $visitor = \XF::visitor(); + $convoRepo->markUserConversationRead($conversation->Users[$visitor->user_id], $maxReadDate); + } + }; + + $messages = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'messages' => $messages, + 'messages_total' => $total + ]; + + $this->transformEntityIfNeeded($data, 'conversation', $conversation); + PageNav::addLinksToData($data, $params, $total, 'conversation-messages'); + + return $this->api($data); + } + + /** + * @param int $messageId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionSingle($messageId) + { + $message = $this->assertViewableMessage($messageId); + + $data = [ + 'message' => $this->transformEntityLazily($message) + ]; + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostIndex() + { + $params = $this + ->params() + ->define('conversation_id', 'uint', 'id of the target conversation') + ->define('message_body', 'str', 'content of the new message') + ->defineAttachmentHash(); + + $conversation = $this->assertViewableConversation($params['conversation_id']); + if (!$conversation->canReply()) { + return $this->noPermission(); + } + + /** @var Replier $replier */ + $replier = $this->service('XF:Conversation\Replier', $conversation, \XF::visitor()); + + $replier->setMessageContent($params['message_body']); + + if ($conversation->canUploadAndManageAttachments()) { + $context = [ + 'conversation_id' => $params['conversation_id'] + ]; + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($context); + + $replier->setAttachmentHash($tempHash); + } + + $replier->checkForSpam(); + + if (!$replier->validate($errors)) { + return $this->error($errors); + } + + $this->assertNotFlooding('conversation'); + + $message = $replier->save(); + return $this->actionSingle($message->message_id); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPutIndex(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + $params = $this + ->params() + ->define('message_body', 'str', 'new content of the message') + ->defineAttachmentHash(); + + $error = null; + if (!$message->canEdit($error)) { + return $this->noPermission($error); + } + + $conversation = $message->Conversation; + if ($conversation === null) { + return $this->noPermission(); + } + + /** @var MessageEditor $editor */ + $editor = $this->service('XF:Conversation\MessageEditor', $message); + $editor->setMessageContent($params['message_body']); + + if ($conversation->canUploadAndManageAttachments()) { + $context = [ + 'message_id' => $message->message_id + ]; + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($context); + + $editor->setAttachmentHash($tempHash); + } + + $editor->checkForSpam(); + + if (!$editor->validate($errors)) { + return $this->error($errors); + } + + $message = $editor->save(); + return $this->actionSingle($message->message_id); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\AbstractReply + */ + public function actionDeleteIndex(ParameterBag $params) + { + return $this->noPermission(); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetAttachments(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + $params = $this + ->params() + ->define('attachment_id', 'uint'); + + if ($params['attachment_id'] > 0) { + return $this->rerouteController('Xfrocks\Api\Controller\Attachment', 'get-data'); + } + + $finder = $message->getRelationFinder('Attachments'); + + $data = [ + 'attachments' => $message->attach_count > 0 ? $this->transformFinderLazily($finder) : [] + ]; + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostAttachments() + { + $params = $this + ->params() + ->defineFile('file') + ->define('conversation_id', 'uint', 'id of the container conversation of the target message') + ->define('message_id', 'uint', 'id of the target message') + ->defineAttachmentHash(); + + if ($params['conversation_id'] === '' && $params['message_id'] === '') { + return $this->error(\XF::phrase('bdapi_slash_conversation_messages_attachments_requires_ids'), 400); + } + + $context = [ + 'conversation_id' => $params['conversation_id'], + 'message_id' => $params['message_id'] + ]; + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($context); + + return $attachmentPlugin->doUpload($tempHash, 'conversation_message', $context); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostReport(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + $params = $this + ->params() + ->define('message', 'str', 'reason of the report'); + + $error = null; + if (!$message->canReport($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Service\Report\Creator $creator */ + $creator = $this->service('XF:Report\Creator', 'conversation_message', $message); + $creator->setMessage($params['message']); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $creator->save(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetLikes(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionGetLikes($message); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostLikes(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($message, true); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteLikes(ParameterBag $params) + { + $message = $this->assertViewableMessage($params->message_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($message, false); + } + + /** + * @param int $messageId + * @param array $extraWith + * @return \XF\Entity\ConversationMessage + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableMessage($messageId, array $extraWith = []) + { + $extraWith[] = 'Conversation.Users|' . \XF::visitor()->user_id; + + /** @var \XF\Entity\ConversationMessage $message */ + $message = $this->assertRecordExists( + 'XF:ConversationMessage', + $messageId, + $extraWith, + 'requested_message_not_found' + ); + + if (!$message->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $message; + } + + /** + * @param \XF\Finder\ConversationMessage $finder + * @param Params $params + * @return void + */ + protected function applyMessagesFilters($finder, Params $params) + { + if ($params['order'] === 'natural_reverse') { + $finder->resetOrder() + ->order('message_date', 'DESC'); + } + + if ($params['before'] > 0) { + $finder->where('message_date', '<', $params['before']); + } + + if ($params['after'] > 0) { + $finder->where('message_date', '>', $params['after']); + } + + $params->limitFinderByPage($finder); + } +} diff --git a/Controller/Error.php b/Controller/Error.php new file mode 100644 index 00000000..1ededa3f --- /dev/null +++ b/Controller/Error.php @@ -0,0 +1,113 @@ +app->controller($params['controller'], $this->request); + $method = substr_replace($params['action'], 'actionPost', 0, 3); + if (is_callable([$controller, $method])) { + return $this->error(\XF::phrase('bdapi_only_accepts_post_requests'), 400); + } + } + + return $this->pluginError()->actionDispatchError($params); + } + + /** + * @see \XF\Pub\Controller\Error::actionException + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\View + */ + public function actionException(ParameterBag $params) + { + return $this->pluginError()->actionException($params->get('exception', false)); + } + + /** + * @see \XF\Pub\Controller\Error::actionAddOnUpgrade + * @return \XF\Mvc\Reply\Error + */ + public function actionAddOnUpgrade() + { + return $this->pluginError()->actionAddOnUpgrade(); + } + + /** + * @return void + */ + public function assertIpNotBanned() + { + // no op + } + + /** + * @return void + */ + public function assertNotBanned() + { + // no op + } + + /** + * @param mixed $action + * @return void + */ + public function assertViewingPermissions($action) + { + // no op + } + + /** + * @param mixed $action + * @return void + */ + public function assertCorrectVersion($action) + { + // no op + } + + /** + * @param mixed $action + * @return void + */ + public function assertBoardActive($action) + { + // no op + } + + /** + * @param mixed $action + * @return void + */ + public function assertTfaRequirement($action) + { + // no op + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } + + /** + * @return \XF\ControllerPlugin\Error + */ + protected function pluginError() + { + /** @var \XF\ControllerPlugin\Error $errorPlugin */ + $errorPlugin = $this->plugin('XF:Error'); + return $errorPlugin; + } +} diff --git a/Controller/Forum.php b/Controller/Forum.php new file mode 100644 index 00000000..7d8b4229 --- /dev/null +++ b/Controller/Forum.php @@ -0,0 +1,204 @@ +assertRegistrationRequired(); + + $visitor = \XF::visitor(); + $finder = $this->finder('XF:ForumWatch') + ->where('user_id', $visitor->user_id); + + if ($this->request()->exists('total')) { + $total = $finder->total(); + + $data = [ + 'forums_total' => $total + ]; + + return $this->api($data); + } + + $finder->with('Forum'); + /** @var ForumWatch[] $forumWatches */ + $forumWatches = $finder->fetch(); + + $forums = []; + $context = $this->params()->getTransformContext(); + $context->onTransformedCallbacks[] = function ($context, &$data) use ($forumWatches) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!($source instanceof \XF\Entity\Forum)) { + return; + } + + $watched = null; + foreach ($forumWatches as $forumWatch) { + if ($forumWatch->node_id == $source->node_id) { + $watched = $forumWatch; + + break; + } + } + + if ($watched !== null) { + $data['follow'] = [ + 'post' => $watched->notify_on == 'message', + 'alert' => $watched->send_alert, + 'email' => $watched->send_email + ]; + } + }; + + foreach ($forumWatches as $forumWatch) { + $forum = $forumWatch->Forum; + if ($forum !== null) { + $forums[] = $this->transformEntityLazily($forum); + } + } + + $data = [ + 'forums' => $forums + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $paramBag + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowers(ParameterBag $paramBag) + { + $forum = $this->assertViewableForum($paramBag->node_id); + + $users = []; + if ($forum->canWatch()) { + $visitor = \XF::visitor(); + + /** @var ForumWatch|null $watched */ + $watched = $this->em()->findOne('XF:ForumWatch', [ + 'user_id' => $visitor->user_id, + 'node_id' => $forum->node_id + ]); + + if ($watched !== null) { + $users[] = [ + 'user_id' => $visitor->user_id, + 'username' => $visitor->username, + 'follow' => [ + 'post' => $watched->notify_on == 'message', + 'alert' => $watched->send_alert, + 'email' => $watched->send_email + ] + ]; + } + } + + $data = [ + 'users' => $users + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostFollowers(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('post', 'uint', 'whether to receive notification for post') + ->define('alert', 'uint', 'whether to receive notification as alert', 1) + ->define('email', 'uint', 'whether to receive notification as email'); + + $forum = $this->assertViewableForum($paramBag->node_id); + + $error = null; + if (!$forum->canWatch($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Repository\ForumWatch $forumWatchRepo */ + $forumWatchRepo = $this->repository('XF:ForumWatch'); + $forumWatchRepo->setWatchState( + $forum, + \XF::visitor(), + $params['post'] > 0 ? 'message' : 'thread', + $params['alert'], + $params['email'] + ); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteFollowers(ParameterBag $paramBag) + { + $forum = $this->assertViewableForum($paramBag->node_id); + + $error = null; + if (!$forum->canWatch($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Repository\ForumWatch $forumWatchRepo */ + $forumWatchRepo = $this->repository('XF:ForumWatch'); + $forumWatchRepo->setWatchState($forum, \XF::visitor(), 'delete'); + + return $this->message(\XF::phrase('changes_saved')); + } + + protected function getNodeTypeId() + { + return 'Forum'; + } + + protected function getNamePlural() + { + return 'forums'; + } + + protected function getNameSingular() + { + return 'forum'; + } + + /** + * @param int $forumId + * @param array $extraWith + * @return \XF\Entity\Forum + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableForum($forumId, array $extraWith = []) + { + $extraWith[] = 'Node.Permissions|' . \XF::visitor()->permission_combination_id; + + /** @var \XF\Entity\Forum $forum */ + $forum = $this->assertRecordExists('XF:Forum', $forumId, $extraWith); + if (!$forum->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $forum; + } +} diff --git a/Controller/Index.php b/Controller/Index.php new file mode 100644 index 00000000..25a64295 --- /dev/null +++ b/Controller/Index.php @@ -0,0 +1,45 @@ +data('Xfrocks\Api:Modules'); + + $systemInfo = []; + $token = $this->session()->getToken(); + if ($token === null) { + $systemInfo += [ + 'oauth/authorize' => $this->app->router('public')->buildLink('account/authorize'), + 'oauth/token' => $this->buildApiLink('oauth/token') + ]; + } elseif ($token->hasScope(Server::SCOPE_POST)) { + $systemInfo += [ + 'api_revision' => 2016062001, + 'api_modules' => $modules->getVersions() + ]; + ksort($systemInfo['api_modules']); + } + + $data = $modules->getDataForApiIndex($this); + ksort($data['links']); + ksort($data['post']); + $data['system_info'] = $systemInfo; + + return $this->api($data); + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } +} diff --git a/Controller/LostPassword.php b/Controller/LostPassword.php new file mode 100644 index 00000000..9daf12ca --- /dev/null +++ b/Controller/LostPassword.php @@ -0,0 +1,48 @@ +params() + ->define('username', 'str') + ->define('email', 'str'); + + $usernameOrEmail = $params['username']; + if (strlen($usernameOrEmail) === 0) { + $usernameOrEmail = $params['email']; + } + if ($usernameOrEmail === '') { + return $this->error(\XF::phrase('bdapi_slash_lost_password_requires_username_or_email'), 400); + } + + $token = $this->session()->getToken(); + if ($token === null) { + return $this->noPermission(); + } + + /** @var \XF\Repository\User $userRepo */ + $userRepo = $this->repository('XF:User'); + /** @var \XF\Entity\User|null $user */ + $user = $userRepo->getUserByNameOrEmail($usernameOrEmail); + if ($user === null) { + return $this->error(\XF::phrase('requested_member_not_found')); + } + + /** @var PasswordReset $passwordReset */ + $passwordReset = $this->service('XF:User\PasswordReset', $user); + if (!$passwordReset->canTriggerConfirmation($error)) { + return $this->error($error); + } + + $passwordReset->triggerConfirmation(); + return $this->message(\XF::phrase('password_reset_request_has_been_emailed_to_you')); + } +} diff --git a/Controller/Navigation.php b/Controller/Navigation.php new file mode 100644 index 00000000..a8d81697 --- /dev/null +++ b/Controller/Navigation.php @@ -0,0 +1,58 @@ +params() + ->define('parent', 'str'); + + $elements = $this->getElements($params['parent']); + return $this->api(['elements' => $elements]); + } + + /** + * @param string $parentNodeId + * @return array + * @throws \XF\Mvc\Reply\Exception + */ + protected function getElements($parentNodeId) + { + $parentNode = null; + $expectParentNodeId = null; + if (is_numeric($parentNodeId)) { + $expectParentNodeId = intval($parentNodeId); + /** @var \XF\Entity\Node|null $parentNode */ + $parentNode = $expectParentNodeId > 0 ? $this->assertRecordExists( + 'XF:Node', + $parentNodeId, + [], + 'bdapi_navigation_element_not_found' + ) : null; + } + + /** @var \XF\Repository\Node $nodeRepo */ + $nodeRepo = $this->repository('XF:Node'); + $nodeList = $nodeRepo->getNodeList($parentNode); + + $nodes = []; + /** @var \XF\Entity\Node $node */ + foreach ($nodeList as $node) { + if ($expectParentNodeId !== null && $node->parent_node_id !== $expectParentNodeId) { + continue; + } + $nodes[] = $node; + } + + /** @var \Xfrocks\Api\ControllerPlugin\Navigation $navigationPlugin */ + $navigationPlugin = $this->plugin('Xfrocks\Api:Navigation'); + return $navigationPlugin->prepareElements($nodes); + } +} diff --git a/Controller/Notification.php b/Controller/Notification.php new file mode 100644 index 00000000..7665ce6c --- /dev/null +++ b/Controller/Notification.php @@ -0,0 +1,211 @@ +assertRegistrationRequired(); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionGetIndex() + { + $params = $this + ->params() + ->definePageNav(); + + /** @var \XF\Repository\UserAlert $alertRepo */ + $alertRepo = $this->repository('XF:UserAlert'); + + $visitor = \XF::visitor(); + $option = $visitor->Option; + if ($option === null) { + throw new \RuntimeException('$option === null'); + } + + $finder = $alertRepo->findAlertsForUser( + $visitor->user_id, + \XF::$time - $this->options()->alertExpiryDays * 86400 + ); + + $params->limitFinderByPage($finder); + + $total = $finder->total(); + $notifications = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'notifications' => $notifications, + 'notifications_total' => $total + ]; + + $topicType = RepoSub::TYPE_NOTIFICATION; + if (RepoSub::getSubOption($topicType)) { + /** @var RepoSub $subscriptionRepo */ + $subscriptionRepo = $this->repository('Xfrocks\Api:Subscription'); + $subscriptionRepo->prepareDiscoveryParams( + $data, + $topicType, + $visitor->user_id, + $this->buildApiLink('notifications', null, ['oauth_token' => '']), + $option->getValue(RepoSub::getSubColumn($topicType)) + ); + } + + PageNav::addLinksToData($data, $params, $total, 'notifications'); + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Message + */ + public function actionPostRead() + { + $visitor = \XF::visitor(); + if ($visitor->alerts_unread > 0) { + /** @var \XF\Repository\UserAlert $alertRepo */ + $alertRepo = $this->repository('XF:UserAlert'); + $alertRepo->markUserAlertsRead(\XF::visitor()); + } + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetContent(ParameterBag $params) + { + /** @var UserAlert $alert */ + $alert = $this->assertRecordExists('XF:UserAlert', $params->alert_id); + if ($alert->alerted_user_id !== \XF::visitor()->user_id) { + return $this->noPermission(); + } + + $jobConfig = $this->getBatchJobConfig($alert); + $contentResponse = null; + + if (is_array($jobConfig)) { + $jobConfig = array_replace([ + 'method' => 'GET', + 'uri' => null, + 'params' => [] + ], $jobConfig); + + $job = new BatchJob($this->app, $jobConfig['method'], $jobConfig['params'], $jobConfig['uri']); + $contentResponse = $job->execute(); + } + + $data = [ + 'notification_id' => $alert->alert_id, + 'notification' => $this->transformEntityLazily($alert) + ]; + + if ($contentResponse !== null) { + /** @var Transformer $transformer */ + $transformer = $this->app()->container('api.transformer'); + + $data = array_merge($data, $transformer->transformBatchJobReply($contentResponse)); + } + + return $this->api($data); + } + + /** + * @param UserAlert $alert + * @return array|null + */ + protected function getBatchJobConfig(UserAlert $alert) + { + switch ($alert->content_type) { + case 'conversation': + switch ($alert->action) { + case 'insert': + case 'join': + case 'reply': + return [ + 'uri' => 'conversation-messages', + 'params' => [ + 'conversation_id' => $alert->content_id + ] + ]; + } + + return [ + 'uri' => 'conversations', + 'params' => [ + 'conversation_id' => $alert->content_id + ] + ]; + case 'thread': + return [ + 'uri' => 'threads', + 'params' => [ + 'thread_id' => $alert->content_id + ] + ]; + case 'post': + return [ + 'uri' => 'posts', + 'params' => [ + 'page_of_post_id' => $alert->content_id + ] + ]; + case 'user': + switch ($alert->action) { + case 'following': + return [ + 'uri' => 'users/followers', + 'params' => [ + 'user_id' => $alert->content_id + ] + ]; + case 'post_copy': + case 'post_move': + case 'thread_merge': + // TODO: Support user alert action (post_copy, post_move, thread_merge) + break; + case 'thread_move': + break; + } + + return [ + 'uri' => 'users', + 'params' => [ + 'user_id' => $alert->content_id + ] + ]; + case 'profile_post': + return [ + 'uri' => 'profile-posts/comments', + 'params' => [ + 'profile_post_id' => $alert->content_id + ] + ]; + case 'profile_post_comment': + return [ + 'uri' => 'profile-posts/comments', + 'params' => [ + 'page_of_comment_id' => $alert->content_id + ] + ]; + } + + return null; + } +} diff --git a/Controller/OAuth2.php b/Controller/OAuth2.php new file mode 100644 index 00000000..3d55e215 --- /dev/null +++ b/Controller/OAuth2.php @@ -0,0 +1,311 @@ +assertCanonicalUrl($this->buildLink('account/authorize')); + + return $this->noPermission(); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \League\OAuth2\Server\Exception\InvalidGrantException + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostToken() + { + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + + return $this->api($apiServer->grantFinalize($this)); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\PrintableException + */ + public function actionPostTokenAdmin() + { + $params = $this->params() + ->define('user_id', 'uint', 'User ID'); + + $session = $this->session(); + $token = $session->getToken(); + if ($token === null) { + return $this->noPermission(); + } + + $client = $token->Client; + if ($client === null) { + return $this->noPermission(); + } + + if (!\XF::visitor()->hasAdminPermission('user')) { + return $this->noPermission(); + } + + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + $scopes = $apiServer->getScopeDefaults(); + $accessToken = $apiServer->newAccessToken($params['user_id'], $client, $scopes); + + return $this->api(Token::transformLibAccessTokenEntity($accessToken)); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostTokenFacebook() + { + $params = $this + ->params() + ->define('client_id', 'str') + ->define('client_secret', 'str') + ->define('facebook_token', 'str'); + + /** @var Client $client */ + $client = $this->assertRecordExists( + 'Xfrocks\Api:Client', + $params['client_id'], + ['User'], + 'bdapi_requested_client_not_found' + ); + + if ($client->client_secret !== $params['client_secret']) { + return $this->noPermission(); + } + + $provider = $this->assertProviderExists('facebook'); + $handler = $provider->getHandler(); + + if ($handler === null || !$handler->isUsable($provider)) { + return $this->noPermission(); + } + + $storageState = $handler->getStorageState($provider, \XF::visitor()); + + $tokenObj = new StdOAuth2Token(); + $tokenObj->setAccessToken($params['facebook_token']); + + $storageState->storeToken($tokenObj); + + /** @var Facebook $providerData */ + $providerData = $handler->getProviderData($storageState); + if (strval($providerData->getProviderKey()) === '') { + return $this->error(\XF::phrase('bdapi_invalid_facebook_token'), 400); + } + + $externalProviderKey = sprintf('fb_%s', $providerData->getProviderKey()); + + $httpClient = $this->app()->http()->client(); + $fbApp = $httpClient->get('https://graph.facebook.com/app', [ + 'query' => [ + 'access_token' => $params['facebook_token'] + ] + ])->getBody()->getContents(); + + $fbApp = json_decode(strval($fbApp), true); + if (isset($fbApp['id']) && $fbApp['id'] === $provider->options['app_id']) { + $externalProviderKey = $providerData->getProviderKey(); + } + + /** @var ConnectedAccount $connectedAccountRepo */ + $connectedAccountRepo = $this->repository('XF:ConnectedAccount'); + $userConnected = $connectedAccountRepo->getUserConnectedAccountFromProviderData($providerData); + if ($userConnected && $userConnected->User) { + return $this->postTokenNonStandard($client, $userConnected->User); + } + + $userData = []; + + if ($email = $providerData->getEmail()) { + /** @var \XF\Entity\User|null $userByEmail */ + $userByEmail = $this->em()->find('XF:User', ['email' => $email]); + + if ($userByEmail !== null) { + $userData['associatable'][$userByEmail->user_id] = [ + 'user_id' => $userByEmail->user_id, + 'username' => $userByEmail->username, + 'user_email' => $userByEmail->email + ]; + } else { + $userData['user_email'] = $email; + } + } + + if ($fbUsername = $providerData->getUsername()) { + $testUser = $this->em()->create('XF:User'); + $testUser->set('username', $fbUsername); + + if (!$testUser->hasErrors()) { + $userData['username'] = $fbUsername; + } + } + + $extraData = [ + 'external_provider' => 'facebook', + 'external_provider_key' => $externalProviderKey, + 'access_token' => $params['facebook_token'] + ]; + + if (isset($userData['user_email'])) { + $extraData['user_email'] = $userData['user_email']; + } + + $extraData = serialize($extraData); + $extraTimestamp = intval(time() + $this->app()->options()->bdApi_refreshTokenTTLDays * 86400); + + $userData += [ + 'extra_data' => Crypt::encryptTypeOne($extraData, $extraTimestamp), + 'extra_timestamp' => $extraTimestamp + ]; + + $data = [ + 'status' => 'ok', + 'message' => \XF::phrase('bdapi_no_facebook_association_found'), + 'user_data' => $userData + ]; + + return $this->api($data); + } + + /** + * @param string $username + * @param string $password + * @return int|false + * @throws \XF\Mvc\Reply\Exception + */ + public function verifyCredentials($username, $password) + { + $ip = $this->request->getIp(); + + /** @var \XF\Service\User\Login $loginService */ + $loginService = $this->service('XF:User\Login', $username, $ip); + if ($loginService->isLoginLimited($limitType)) { + throw $this->errorException(\XF::phrase('your_account_has_temporarily_been_locked_due_to_failed_login_attempts')); + } + + $user = $loginService->validate($password, $error); + if (!$user) { + return false; + } + + if (!$this->runTfaValidation($user)) { + return false; + } + + return $user->user_id; + } + + /** + * @param \XF\Entity\User $user + * @return bool + * @throws \XF\Mvc\Reply\Exception + */ + protected function runTfaValidation(\XF\Entity\User $user) + { + $params = $this + ->params() + ->define('tfa_provider', 'str') + ->define('tfa_trigger', 'bool'); + + /** @var \XF\ControllerPlugin\Login $loginPlugin */ + $loginPlugin = $this->plugin('XF:Login'); + if (!$loginPlugin->isTfaConfirmationRequired($user)) { + return true; + } + + /** @var Tfa $tfaRepo */ + $tfaRepo = $this->repository('XF:Tfa'); + $providers = $tfaRepo->getAvailableProvidersForUser($user->user_id); + if (count($providers) === 0) { + return true; + } + + $response = $this->app()->response(); + $response->header('X-Api-Tfa-Providers', implode(', ', array_keys($providers))); + + $tfaProvider = $params['tfa_provider']; + if (!isset($providers[$tfaProvider])) { + throw $this->errorException(\XF::phrase('two_step_verification_required'), 202); + } + /** @var \XF\Service\User\Tfa $tfaService */ + $tfaService = $this->service('XF:User\Tfa', $user); + + if ($params['tfa_trigger'] === true) { + $tfaService->trigger($this->request(), $tfaProvider); + throw $this->exception($this->message(\XF::phrase('changes_saved'))); + } + + if ($tfaService->hasTooManyTfaAttempts()) { + throw $this->errorException( + \XF::phrase('your_account_has_temporarily_been_locked_due_to_failed_login_attempts') + ); + } + + if ($tfaService->verify($this->request, $tfaProvider)) { + return true; + } + + throw $this->errorException(\XF::phrase('two_step_verification_value_could_not_be_confirmed')); + } + + /** + * @param Client $client + * @param \XF\Entity\User $user + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\PrintableException + */ + protected function postTokenNonStandard(Client $client, \XF\Entity\User $user) + { + /** @var Server $apiServer */ + $apiServer = $this->app()->container('api.server'); + $scopes = $apiServer->getScopeDefaults(); + + $token = $apiServer->newAccessToken($user->user_id, $client, $scopes); + $refreshToken = $apiServer->newRefreshToken($user->user_id, $client, $scopes); + + return $this->api(Token::transformLibAccessTokenEntity($token, $refreshToken)); + } + + /** + * @param string $id + * @param null $with + * @param null $phraseKey + * @return ConnectedAccountProvider + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertProviderExists($id, $with = null, $phraseKey = null) + { + /** @var ConnectedAccountProvider $provider */ + $provider = $this->assertRecordExists('XF:ConnectedAccountProvider', $id, $with, $phraseKey); + + return $provider; + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } +} diff --git a/Controller/Page.php b/Controller/Page.php new file mode 100644 index 00000000..ac26b0e5 --- /dev/null +++ b/Controller/Page.php @@ -0,0 +1,21 @@ +post_id) { + return $this->actionSingle($params->post_id); + } + + $params = $this->params() + ->define('thread_id', 'uint', 'thread id to filter', 0) + ->define('page_of_post_id', 'uint', 'post id to filter for thread and page', 0) + ->defineOrder([ + 'natural' => ['position', 'asc'], + 'natural_reverse' => ['position', 'desc'], + 'post_create_date' => ['post_date', 'asc'], + 'post_create_date_reverse' => ['post_date', 'desc'] + ]) + ->definePageNav() + ->define('post_ids', 'str', 'post ids to fetch (ignoring all filters, separated by comma)'); + + if ($params['post_ids'] !== '') { + $postIds = $params->filterCommaSeparatedIds('post_ids'); + + return $this->actionMultiple($postIds); + } + + if ($params['thread_id'] < 1 && $params['page_of_post_id'] < 1) { + $this->assertValidToken(); + } + + /** @var \XF\Finder\Post $finder */ + $finder = $this->finder('XF:Post'); + $this->applyFilters($finder, $params); + $params->sortFinder($finder); + $params->limitFinderByPage($finder); + + $total = $finder->total(); + $posts = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'posts' => $posts, + 'posts_total' => $total + ]; + + if ($params['thread_id'] > 0) { + $thread = $this->assertViewableThread($params['thread_id']); + $this->transformEntityIfNeeded($data, 'thread', $thread); + } + + PageNav::addLinksToData($data, $params, $total, 'posts'); + + return $this->api($data); + } + + /** + * @param array $ids + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionMultiple(array $ids) + { + $posts = []; + if (count($ids) > 0) { + $posts = $this->findAndTransformLazily('XF:Post', $ids); + } + + return $this->api(['posts' => $posts]); + } + + /** + * @param int $postId + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionSingle($postId) + { + return $this->api([ + 'post' => $this->findAndTransformLazily('XF:Post', intval($postId), 'requested_post_not_found') + ]); + } + + /** + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostIndex() + { + $params = $this + ->params() + ->define('thread_id', 'uint', 'id of the target thread') + ->define('quote_post_id', 'uint', 'id of the quote post') + ->define('post_body', 'str', 'content of the new post') + ->defineAttachmentHash(); + + if ($params['quote_post_id'] > 0) { + $post = $this->assertViewablePost($params['quote_post_id']); + if ($params['thread_id'] > 0 && $post->thread_id !== $params['thread_id']) { + return $this->noPermission(); + } + + $thread = $post->Thread; + if ($thread === null) { + return $this->notFound(); + } + + $postBody = $post->getQuoteWrapper($post->message) . $params['post_body']; + } else { + $thread = $this->assertViewableThread($params['thread_id']); + + $postBody = $params['post_body']; + } + + if (!$thread->canReply($error)) { + return $this->noPermission($error); + } + + /** @var Replier $replier */ + $replier = $this->service('XF:Thread\Replier', $thread); + + $replier->setMessage($postBody); + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash([ + 'thread_id' => $thread->thread_id + ]); + + $replier->setAttachmentHash($tempHash); + + $replier->checkForSpam(); + + if (!$replier->validate($errors)) { + return $this->error($errors); + } + + $post = $replier->save(); + + return $this->actionSingle($post->post_id); + } + + /** + * @param ParameterBag $pb + * @return mixed|\XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPutIndex(ParameterBag $pb) + { + $post = $this->assertViewablePost($pb->post_id); + + $params = $this + ->params() + ->define('post_body', 'str', 'new content of the post') + ->define('thread_title', 'str', 'new title of the thread') + ->define('thread_prefix_id', 'uint', 'new id of the thread\'s prefix') + ->define('thread_tags', 'str', 'new tags of the thread') + ->define('fields', 'array') + ->defineAttachmentHash(); + + if (!$post->canEdit($error)) { + return $this->noPermission($error); + } + + $thread = $post->Thread; + if ($thread === null) { + throw new \RuntimeException('$thread === null'); + } + + /** @var Editor $editor */ + $editor = $this->service('XF:Post\Editor', $post); + + $editor->setMessage($params['post_body']); + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $attachmentContentData = $pb->_attachmentContentData; + if (!is_array($attachmentContentData)) { + $attachmentContentData = ['post_id' => $post->post_id]; + } + $tempHash = $attachmentPlugin->getAttachmentTempHash($attachmentContentData); + + $editor->setAttachmentHash($tempHash); + + $editor->checkForSpam(); + + $threadEditor = null; + $tagger = null; + $errors = []; + + if ($post->isFirstPost()) { + /** @var \XF\Service\Thread\Editor $threadEditor */ + $threadEditor = $this->service('XF:Thread\Editor', $thread); + + if ($this->request()->exists('thread_title')) { + $threadEditor->setTitle($params['thread_title']); + } + + $threadEditor->setPrefix($params['thread_prefix_id']); + + if ($this->request()->exists('fields')) { + $threadEditor->setCustomFields($params['fields']); + } + + if ($thread->canEditTags()) { + /** @var \XF\Service\Tag\Changer $tagger */ + $tagger = $this->service('XF:Tag\Changer', 'thread', $thread); + + $tagger->setEditableTags($params['thread_tags']); + + if ($tagger->hasErrors()) { + $errors = array_merge($errors, $tagger->getErrors()); + } + } + + $threadErrors = []; + $threadEditor->validate($threadErrors); + + $errors = array_merge($errors, $threadErrors); + } + + $postErrors = []; + $editor->validate($postErrors); + + $errors = array_merge($errors, $postErrors); + if (count($errors) > 0) { + return $this->error($errors); + } + + $post = $editor->save(); + + if ($threadEditor !== null) { + $threadEditor->save(); + } + + if ($tagger !== null) { + $tagger->save(); + } + + if (is_callable($pb->_replyCallback)) { + return call_user_func($pb->_replyCallback, $this); + } + + return $this->actionSingle($post->post_id); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteIndex(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + $params = $this + ->params() + ->define('reason', 'str', 'reason of the post removal'); + + if (!$post->canDelete('soft', $error)) { + return $this->noPermission($error); + } + + /** @var Deleter $deleter */ + $deleter = $this->service('XF:Post\Deleter', $post); + $deleter->delete('soft', $params['reason']); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetAttachments(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + $params = $this + ->params() + ->define('attachment_id', 'uint'); + + if ($params['attachment_id'] > 0) { + return $this->rerouteController('Xfrocks\Api\Controller\Attachment', 'get-data'); + } + + $finder = $post->getRelationFinder('Attachments'); + + $data = [ + 'attachments' => $this->transformFinderLazily($finder) + ]; + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostAttachments() + { + $params = $this + ->params() + ->defineFile('file', 'binary data of the attachment') + ->define('thread_id', 'uint', 'id of the container thread of the target post') + ->define('post_id', 'uint', 'id of the target post') + ->defineAttachmentHash(); + + if ($params['post_id'] === '' && $params['thread_id'] === '') { + return $this->error(\XF::phrase('bdapi_slash_posts_attachments_requires_ids'), 400); + } + + $context = [ + 'thread_id' => $params['thread_id'], + 'post_id' => $params['post_id'] + ]; + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($context); + + return $attachmentPlugin->doUpload($tempHash, 'post', $context); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetLikes(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionGetLikes($post); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostLikes(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($post, true); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteLikes(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($post, false); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostReport(ParameterBag $params) + { + $post = $this->assertViewablePost($params->post_id); + + $params = $this + ->params() + ->define('message', 'str', 'reason of the report'); + + if (!$post->canReport($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Service\Report\Creator $creator */ + $creator = $this->service('XF:Report\Creator', 'post', $post); + $creator->setMessage($params['message']); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $creator->save(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param int $postId + * @param array $extraWith + * @return \XF\Entity\Post + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewablePost($postId, array $extraWith = []) + { + $extraWith[] = 'Thread.Forum.Node.Permissions|' . \XF::visitor()->permission_combination_id; + + /** @var \XF\Entity\Post $post */ + $post = $this->assertRecordExists('XF:Post', $postId, $extraWith, 'requested_post_not_found'); + + if (!$post->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $post; + } + + /** + * @param int $threadId + * @param array $extraWith + * @return \XF\Entity\Thread + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableThread($threadId, array $extraWith = []) + { + $extraWith[] = 'Forum.Node.Permissions|' . \XF::visitor()->permission_combination_id; + + /** @var \XF\Entity\Thread $thread */ + $thread = $this->assertRecordExists('XF:Thread', $threadId, $extraWith, 'requested_thread_not_found'); + + if (!$thread->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $thread; + } + + /** + * @param \XF\Finder\Post $finder + * @param Params $params + * @return void + * @throws \XF\Mvc\Reply\Exception + */ + protected function applyFilters($finder, Params $params) + { + if ($params['page_of_post_id'] > 0) { + $post = $this->assertViewablePost($params['page_of_post_id']); + $params['page_of_post_id'] = 0; + $params['thread_id'] = $post->thread_id; + + list($limit,) = $params->filterLimitAndPage(); + + if ($params['order'] !== 'natural') { + // force natural ordering + $params['order'] = 'natural'; + } + $params['page'] = floor($post->position / $limit) + 1; + } + + if ($params['thread_id'] > 0) { + $thread = $this->assertViewableThread($params['thread_id']); + $finder->inThread($thread); + } + } +} diff --git a/Controller/ProfilePost.php b/Controller/ProfilePost.php new file mode 100644 index 00000000..f1c11c48 --- /dev/null +++ b/Controller/ProfilePost.php @@ -0,0 +1,396 @@ +params() + ->define('profile_post_ids', 'str'); + + if ($params['profile_post_ids'] !== '') { + $profilePostIds = $params->filterCommaSeparatedIds('profile_post_ids'); + + return $this->actionMultiple($profilePostIds); + } + + return $this->actionSingle($paramBag->profile_post_id); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPutIndex(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('post_body', 'str', 'new content of the profile post'); + + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + if (!$profilePost->canEdit($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Service\ProfilePost\Editor $editor */ + $editor = $this->service('XF:ProfilePost\Editor', $profilePost); + $editor->setMessage($params['post_body']); + + if (!$editor->validate($errors)) { + return $this->error($errors); + } + + $profilePost = $editor->save(); + + return $this->actionSingle($profilePost->profile_post_id); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteIndex(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('reason', 'str'); + + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + if (!$profilePost->canDelete('soft', $error)) { + return $this->noPermission($error); + } + + /** @var \XF\Service\ProfilePost\Deleter $deleter */ + $deleter = $this->service('XF:ProfilePost\Deleter', $profilePost); + $deleter->delete('soft', $params['reason']); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $paramBag + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetLikes(ParameterBag $paramBag) + { + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionGetLikes($profilePost); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostLikes(ParameterBag $paramBag) + { + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($profilePost, true); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteLikes(ParameterBag $paramBag) + { + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + /** @var Like $likePlugin */ + $likePlugin = $this->plugin('Xfrocks\Api:Like'); + return $likePlugin->actionToggleLike($profilePost, true); + } + + /** + * @param ParameterBag $paramBag + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetComments(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('before', 'uint') + ->define('page_of_comment_id', 'uint') + ->define('comment_id', 'uint') + ->definePageNav(); + + /** @var ProfilePostComment|null $pageOfComment */ + $pageOfComment = null; + if ($params['page_of_comment_id'] > 0) { + $pageOfComment = $this->assertViewableComment($params['page_of_comment_id']); + $profilePost = $pageOfComment->ProfilePost; + } else { + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + if ($params['comment_id'] > 0) { + $oneComment = $this->assertViewableComment($params['comment_id']); + if ($oneComment->profile_post_id != $profilePost->profile_post_id) { + return $this->noPermission(); + } + + $data = [ + 'comment' => $this->transformEntityLazily($oneComment) + ]; + + return $this->api($data); + } + } + + if ($profilePost === null) { + throw new \RuntimeException('$profilePost === null'); + } + $profileUser = $profilePost->ProfileUser; + + $beforeDate = $params['before']; + if ($pageOfComment !== null) { + $beforeDate = $pageOfComment->comment_date + 1; + } + + list($limit,) = $params->filterLimitAndPage(); + + /** @var \XF\Repository\ProfilePost $profilePostRepo */ + $profilePostRepo = $this->repository('XF:ProfilePost'); + $finder = $profilePostRepo->findNewestCommentsForProfilePost($profilePost, $beforeDate); + + $finder->limit($limit); + + $comments = $finder->fetch()->reverse(true); + + /** @var ProfilePostComment|false $oldestComment */ + $oldestComment = $comments->first(); + /** @var ProfilePostComment|false $latestComment */ + $latestComment = $comments->last(); + + $data = [ + 'comments' => [], + 'comment_total' => $profilePost->comment_count, + 'links' => [], + 'profile_post' => $this->transformEntityLazily($profilePost), + 'timeline_user' => $profileUser !== null ? $this->transformEntityLazily($profileUser) : null, + ]; + + foreach ($comments as $comment) { + $data['comments'][] = $this->transformEntityLazily($comment); + } + + if ($oldestComment !== false && $oldestComment->comment_date !== $profilePost->first_comment_date) { + $data['links']['prev'] = $this->buildApiLink( + 'profile-posts/comments', + $profilePost, + ['before' => $oldestComment->comment_date] + ); + } + + if ($latestComment !== false && $latestComment->comment_date !== $profilePost->last_comment_date) { + $data['links']['latest'] = $this->buildApiLink( + 'profile-posts/comments', + $profilePost + ); + } + + return $this->api($data); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostComments(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('comment_body', 'str'); + + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + if (!$profilePost->canComment($error)) { + return $this->noPermission($error); + } + + /** @var Creator $creator */ + $creator = $this->service('XF:ProfilePostComment\Creator', $profilePost); + $creator->setContent($params['comment_body']); + + $creator->checkForSpam(); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $this->assertNotFlooding('post'); + + /** @var ProfilePostComment $comment */ + $comment = $creator->save(); + $creator->sendNotifications(); + + $this->request()->set('comment_id', $comment->profile_post_comment_id); + return $this->rerouteController(__CLASS__, 'get-comments', [ + 'profile_post_id' => $profilePost->profile_post_id + ]); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteComments(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('comment_id', 'uint') + ->define('reason', 'str'); + + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + $comment = $this->assertViewableComment($params['comment_id']); + + if ($comment->profile_post_id != $profilePost->profile_post_id) { + return $this->noPermission(); + } + + if (!$comment->canDelete('soft', $error)) { + return $this->noPermission($error); + } + + /** @var Deleter $deleter */ + $deleter = $this->service('XF:ProfilePostComment\Deleter', $comment); + $deleter->delete('soft', $params['reason']); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostReport(ParameterBag $paramBag) + { + $params = $this->params()->define('message', 'str'); + + $profilePost = $this->assertViewableProfilePost($paramBag->profile_post_id); + + if (!$profilePost->canReport($error)) { + return $this->noPermission($error); + } + + /** @var \XF\Service\Report\Creator $creator */ + $creator = $this->service('XF:Report\Creator', 'profile_post', $profilePost); + $creator->setMessage($params['message']); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $creator->save(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param array $ids + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionMultiple(array $ids) + { + $profilePosts = []; + if (count($ids) > 0) { + $profilePosts = $this->findAndTransformLazily('XF:ProfilePost', $ids); + } + + return $this->api(['profile_posts' => $profilePosts]); + } + + /** + * @param int $profilePostId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionSingle($profilePostId) + { + $profilePost = $this->assertViewableProfilePost($profilePostId); + + $data = [ + 'profile_post' => $this->transformEntityLazily($profilePost) + ]; + + return $this->api($data); + } + + /** + * @param int $commentId + * @param array $extraWith + * @return ProfilePostComment + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableComment($commentId, array $extraWith = []) + { + $extraWith[] = 'User'; + $extraWith[] = 'ProfilePost.ProfileUser.Privacy'; + + /** @var ProfilePostComment $comment */ + $comment = $this->assertRecordExists( + 'XF:ProfilePostComment', + $commentId, + $extraWith, + 'requested_comment_not_found' + ); + + if (!$comment->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $comment; + } + + /** + * @param int $profilePostId + * @param array $extraWith + * @return \XF\Entity\ProfilePost + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableProfilePost($profilePostId, array $extraWith = []) + { + $extraWith[] = 'User'; + $extraWith[] = 'ProfileUser.Privacy'; + + /** @var \XF\Entity\ProfilePost $profilePost */ + $profilePost = $this->assertRecordExists( + 'XF:ProfilePost', + $profilePostId, + $extraWith, + 'requested_profile_post_not_found' + ); + + if (!$profilePost->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $profilePost; + } +} diff --git a/Controller/Search.php b/Controller/Search.php new file mode 100644 index 00000000..af3a3a6d --- /dev/null +++ b/Controller/Search.php @@ -0,0 +1,178 @@ + [ + 'posts' => $this->buildApiLink('search/posts'), + 'threads' => $this->buildApiLink('search/threads') + ] + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetResults(ParameterBag $params) + { + $search = $this->assertViewableSearch($params->search_id); + + $params = $this + ->params() + ->definePageNav(); + + list($perPage, $page) = $params->filterLimitAndPage(); + + $searcher = $this->app()->search(); + $resultSet = $searcher->getResultSet($search->search_results); + + $resultSet->sliceResultsToPage($page, $perPage, false); + + if (!$resultSet->countResults()) { + return $this->error(\XF::phrase('no_results_found'), 400); + } + + /** @var \Xfrocks\Api\ControllerPlugin\Search $searchPlugin */ + $searchPlugin = $this->plugin('Xfrocks\Api:Search'); + + $data = [ + 'data_total' => $search->result_count, + 'data' => $searchPlugin->prepareSearchResults($resultSet->getResults()) + ]; + + PageNav::addLinksToData($data, $params, $data['data_total'], 'search/results', $search); + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Reroute + * @throws \XF\PrintableException + */ + public function actionPostThreads() + { + if (!\XF::visitor()->canSearch($error)) { + return $this->noPermission($error); + } + + $params = $this + ->params() + ->define('q', 'str', 'query to search for') + ->define('forum_id', 'uint', 'forum id to filter') + ->define('user_id', 'uint', 'creator user id to filter'); + + if ($params['q'] === '' && $params['user_id'] === 0) { + return $this->error(\XF::phrase('bdapi_slash_search_requires_q'), 400); + } + + $search = $this->searchRepo()->search($params, 'thread'); + if ($search === null) { + return $this->error(\XF::phrase('no_results_found'), 400); + } + + return $this->rerouteController(__CLASS__, 'getResults', ['search_id' => $search->search_id]); + } + + /** + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Reroute + * @throws \XF\PrintableException + */ + public function actionPostPosts() + { + if (!\XF::visitor()->canSearch($error)) { + return $this->noPermission($error); + } + + $params = $this + ->params() + ->define('q', 'str', 'query to search for') + ->define('forum_id', 'uint', 'id of the container forum to search for contents') + ->define('thread_id', 'uint', 'id of the container thread to search for posts') + ->define('user_id', 'uint', 'id of the creator to search for contents'); + + if ($params['q'] === '' && $params['user_id'] === 0) { + return $this->error(\XF::phrase('bdapi_slash_search_requires_q'), 400); + } + + $search = $this->searchRepo()->search($params, 'post'); + if ($search === null) { + // no results. + return $this->error(\XF::phrase('no_results_found'), 400); + } + + return $this->rerouteController(__CLASS__, 'getResults', ['search_id' => $search->search_id]); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message|\XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + */ + public function actionUserTimeline(ParameterBag $params) + { + /** @var \XF\Entity\User $user */ + $user = $this->assertRecordExists('XF:User', $params->user_id, [], 'requested_user_not_found'); + + $searcher = $this->app->search(); + $query = $searcher->getQuery(); + $query->byUserId($user->user_id) + ->orderedBy('date'); + + /** @var \XF\Repository\Search $searchRepo */ + $searchRepo = $this->repository('XF:Search'); + $search = $searchRepo->runSearch($query, [ + 'users' => $user->username + ], false); + + if (!$search) { + return $this->message(\XF::phrase('no_results_found')); + } + + return $this->rerouteController(__CLASS__, 'get-results', [ + 'search_id' => $search->search_id + ]); + } + + /** + * @param int $searchId + * @param array $extraWith + * @return \XF\Entity\Search + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableSearch($searchId, array $extraWith = []) + { + /** @var \XF\Entity\Search $search */ + $search = $this->assertRecordExists('XF:Search', $searchId, $extraWith, 'no_results_found'); + + if ($search->user_id !== \XF::visitor()->user_id) { + throw $this->exception($this->notFound(\XF::phrase('no_results_found'))); + } + + return $search; + } + + /** + * @return \Xfrocks\Api\Repository\Search + */ + protected function searchRepo() + { + /** @var \Xfrocks\Api\Repository\Search $searchRepo */ + $searchRepo = $this->repository('Xfrocks\Api:Search'); + + return $searchRepo; + } +} diff --git a/Controller/Subscription.php b/Controller/Subscription.php new file mode 100644 index 00000000..1d393e0c --- /dev/null +++ b/Controller/Subscription.php @@ -0,0 +1,166 @@ +params() + ->define('hub_callback', 'str') + ->define('hub_mode', 'str') + ->define('hub_topic', 'str') + ->define('hub_lease_seconds', 'str') + ->define('client_id', 'str'); + + $session = $this->session(); + $token = $session->getToken(); + + $clientId = ''; + if ($token !== null) { + $clientId = $token->client_id; + } + + $isSessionClientId = true; + if ($clientId === '') { + $clientId = $params['client_id']; + $isSessionClientId = false; + } + + if ($clientId === '') { + return $this->noPermission(); + } + + $validator = $this->app->validator('XF:Url'); + if (!$validator->isValid($params['hub_callback'])) { + return $this->responseError(\XF::phrase('bdapi_subscription_callback_is_required')); + } + + if (!in_array($params['hub_mode'], [ + 'subscribe', + 'unsubscribe' + ], true)) { + return $this->responseError(\XF::phrase('bdapi_subscription_mode_must_valid')); + } + + /** @var \Xfrocks\Api\Entity\Subscription[]|null $existingSubscriptions */ + $existingSubscriptions = null; + $hubTopic = $params['hub_topic']; + + if (!$this->subscriptionRepo()->isValidTopic($hubTopic)) { + return $this->responseError(\XF::phrase('bdapi_subscription_topic_not_recognized')); + } + + if ($params['hub_mode'] === 'subscribe') { + if (!$isSessionClientId) { + return $this->noPermission(); + } + } else { + $existingSubscriptions = $this->finder('Xfrocks\Api:Subscription') + ->where('client_id', $clientId) + ->where('topic', $hubTopic) + ->fetch(); + + if ($existingSubscriptions->count() > 0) { + foreach ($existingSubscriptions->keys() as $key) { + if ($existingSubscriptions[$key]['callback'] != $params['hub_callback']) { + unset($existingSubscriptions[$key]); + } + } + } + } + + $verified = $this->subscriptionRepo()->verifyIntentOfSubscriber( + $params['hub_callback'], + $params['hub_mode'], + $hubTopic, + $params['hub_lease_seconds'], + ['client_id' => $clientId] + ); + + if ($verified) { + switch ($params['hub_mode']) { + case 'unsubscribe': + foreach ($existingSubscriptions as $subscription) { + try { + $subscription->delete(); + } catch (\Exception $e) { + // ignore + } + } + + $this->subscriptionRepo()->updateCallbacksForTopic($hubTopic); + break; + default: + $subscriptions = $this->finder('Xfrocks\Api:Subscription') + ->where('client_id', $clientId) + ->where('topic', $hubTopic) + ->fetch(); + + /** @var \Xfrocks\Api\Entity\Subscription $subscription */ + foreach ($subscriptions as $subscription) { + if ($subscription->callback === $params['hub_callback']) { + try { + $subscription->delete(); + } catch (\Exception $e) { + // ignore + } + } + } + + /** @var \Xfrocks\Api\Entity\Subscription $subscriptionEntity */ + $subscriptionEntity = $this->em()->create('Xfrocks\Api:Subscription'); + $subscriptionEntity->client_id = $clientId; + $subscriptionEntity->callback = $params['hub_callback']; + $subscriptionEntity->topic = $hubTopic; + + if ($params['hub_lease_seconds'] > 0) { + $subscriptionEntity->expire_date = \XF::$time + $params['hub_lease_seconds']; + } + + $subscriptionEntity->save(); + } + + $this->setResponseType('raw'); + return $this->view('Xfrocks\Api:Subscription\Post', 'DEFAULT', [ + 'httpResponseCode' => 202 + ]); + } + + return $this->responseError(\XF::phrase('bdapi_subscription_cannot_verify_intent_of_subscriber')); + } + + /** + * @param mixed $error + * @return \XF\Mvc\Reply\View + */ + protected function responseError($error) + { + $this->setResponseType('raw'); + + return $this->view('Xfrocks\Api:Subscription\Post', 'DEFAULT', [ + 'httpResponseCode' => 400, + 'message' => $error + ]); + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } + + /** + * @return \Xfrocks\Api\Repository\Subscription + */ + protected function subscriptionRepo() + { + /** @var \Xfrocks\Api\Repository\Subscription $repo */ + $repo = $this->repository('Xfrocks\Api:Subscription'); + + return $repo; + } +} diff --git a/Controller/Tag.php b/Controller/Tag.php new file mode 100644 index 00000000..1adaae71 --- /dev/null +++ b/Controller/Tag.php @@ -0,0 +1,154 @@ +tag_id > 0) { + return $this->actionSingle($params->tag_id); + } + + $tagCloud = $this->options()->tagCloud; + $tags = []; + if ((bool)$tagCloud['enabled']) { + $results = $this->tagRepo()->getTagsForCloud($tagCloud['count'], $this->options()->tagCloudMinUses); + foreach ($results as $result) { + $tags[] = $this->transformEntityLazily($result); + } + } + + $data = [ + 'tags' => $tags + ]; + + return $this->api($data); + } + + /** + * @param int $tagId + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\PrintableException + */ + public function actionSingle($tagId) + { + $params = $this->params() + ->definePageNav(); + + /** @var \XF\Entity\Tag|null $tag */ + $tag = $this->em()->find('XF:Tag', $tagId); + if ($tag === null) { + return $this->error(\XF::phrase('requested_tag_not_found'), 404); + } + + list($perPage, $page) = $params->filterLimitAndPage(); + + $cache = $this->tagRepo()->getTagResultCache($tag->tag_id, 0); + if ($cache->result_cache_id > 0) { + $contentTags = $cache->results; + } else { + $contentTags = $this->tagRepo()->getTagSearchResults($tag->tag_id, $this->options()->maximumSearchResults); + + $insertCache = count($contentTags) > $perPage; + if ($insertCache) { + $cache->results = $contentTags; + $cache->save(); + } + } + + $totalResults = count($contentTags); + $resultSet = $this->tagRepo()->getTagResultSet($contentTags); + + $resultSet->sliceResultsToPage($page, $perPage, false); + if (!$resultSet->countResults()) { + return $this->error(\XF::phrase('no_results_found'), 400); + } + + /** @var \Xfrocks\Api\ControllerPlugin\Search $searchPlugin */ + $searchPlugin = $this->plugin('Xfrocks\Api:Search'); + $contentData = $searchPlugin->prepareSearchResults($resultSet->getResults()); + + $data = [ + 'tag' => $this->transformEntityLazily($tag), + 'tagged' => array_values($contentData), + 'tagged_total' => $totalResults + ]; + + PageNav::addLinksToData($data, $params, $totalResults, 'tags', $tag); + + return $this->api($data); + } + + public function actionGetFind() + { + $params = $this->params() + ->define('q', 'str'); + $this->assertApiScope(Server::SCOPE_POST); + + $q = $this->tagRepo()->normalizeTag($params['q']); + + $ids = []; + $tags = []; + if (strlen($q) >= 2) { + $results = $this->tagRepo()->getTagAutoCompleteResults($q); + foreach ($results as $result) { + $tagTransformed = $this->transformEntityLazily($result)->transform(); + if (is_array($tagTransformed)) { + $ids[] = $tagTransformed['tag_id']; + $tags[] = $tagTransformed; + } + } + } + + $data = [ + 'ids' => [], + 'tags' => [] + ]; + return $this->api($data); + } + + public function actionGetList() + { + $params = $this->params() + ->definePageNav(); + $this->assertValidToken(); + + /** @var \XF\Entity\Tag|null $latestTag */ + $latestTag = $this->finder('XF:Tag') + ->order('tag_id', 'desc') + ->fetchOne(); + $total = ($latestTag !== null) ? $latestTag->tag_id : 0; + + list($perPage, $page) = $params->filterLimitAndPage(); + + $tagIdEnd = $page * $perPage; + $tagIdStart = $tagIdEnd - $perPage + 1; + + $tags = $this->finder('XF:Tag') + ->where('tag_id', 'BETWEEN', [$tagIdStart, $tagIdEnd]); + + $data = [ + 'tags' => $this->transformFinderLazily($tags), + 'tags_total' => $total + ]; + + PageNav::addLinksToData($data, $params, $total, 'tags/list'); + return $this->api($data); + } + + /** + * @return \XF\Repository\Tag + */ + protected function tagRepo() + { + /** @var \XF\Repository\Tag $tagRepo */ + $tagRepo = $this->repository('XF:Tag'); + + return $tagRepo; + } +} diff --git a/Controller/Thread.php b/Controller/Thread.php new file mode 100644 index 00000000..e8724b39 --- /dev/null +++ b/Controller/Thread.php @@ -0,0 +1,705 @@ +thread_id) { + return $this->actionSingle($params->thread_id); + } + + $params = $this->params() + ->define('forum_id', 'uint', 'forum id to filter') + ->define('creator_user_id', 'uint', 'creator user id to filter') + ->define('sticky', 'bool', 'sticky to filter') + ->define('thread_prefix_id', 'uint', 'thread prefix id to filter') +// ->define('thread_tag_id', 'uint', 'thread tag id to filter') + ->defineOrder([ + 'thread_create_date' => ['post_date', 'asc'], + 'thread_create_date_reverse' => ['post_date', 'desc'], + 'thread_update_date' => ['last_post_date', 'asc', '_whereOp' => '>'], + 'thread_update_date_reverse' => ['last_post_date', 'desc', '_whereOp' => '<'], + 'thread_view_count' => ['view_count', 'asc'], + 'thread_view_count_reverse' => ['view_count', 'asc'], + ]) + ->definePageNav() + ->define(TransformThread::KEY_UPDATE_DATE, 'uint', 'timestamp to filter') + ->define('thread_ids', 'str', 'thread ids to fetch (ignoring all filters, separated by comma)'); + + if ($params['thread_ids'] !== '') { + $threadIds = $params->filterCommaSeparatedIds('thread_ids'); + + return $this->actionMultiple($threadIds); + } + + if ($params['forum_id'] < 1) { + $this->assertValidToken(); + } + + /** @var \XF\Finder\Thread $finder */ + $finder = $this->finder('XF:Thread'); + $this->applyFilters($finder, $params); + + $orderChoice = $params->sortFinder($finder); + if (is_array($orderChoice)) { + switch ($orderChoice[0]) { + case 'last_post_date': + $keyUpdateDate = TransformThread::KEY_UPDATE_DATE; + if ($params[$keyUpdateDate] > 0) { + $finder->where($orderChoice[0], $orderChoice['_whereOp'], $params[$keyUpdateDate]); + } + break; + } + } + + $params->limitFinderByPage($finder); + + $total = $finder->total(); + + $threads = []; + if ($total > 0) { + $threads = $this->transformFinderLazily($finder); + + if (!$this->request()->exists('sticky')) { + if ($params['forum_id'] > 0 && $params['page'] === 1) { + // first page without `sticky` param specify + // include stickied threads on top of the results + $sticky = clone $finder; + $sticky->where('sticky', 1); + + $threads->addCallbackFinderPostFetch(function ($normals) use ($params, $sticky) { + // call handler to prepare finder + /** @var Transformer $transformer */ + $transformer =\XF::app()->container('api.transformer'); + $handler = $transformer->handler('XF:Thread'); + $handler->onTransformFinder($params->getTransformContext(), $sticky); + + $threads = $sticky->fetch()->toArray(); + foreach ($normals as $thread) { + $threads[] = $thread; + } + + return $threads; + }); + } + + $finder->where('sticky', 0); + } + } + + $data = [ + 'threads' => $threads, + 'threads_total' => $total + ]; + + if ($params['forum_id'] > 0) { + $forum = $this->assertViewableForum($params['forum_id']); + $this->transformEntityIfNeeded($data, 'forum', $forum); + } + + PageNav::addLinksToData($data, $params, $total, 'threads'); + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\Error|\Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostIndex() + { + $params = $this + ->params() + ->define('forum_id', 'uint', 'id of the target forum') + ->define('thread_title', 'str', 'title of the new thread') + ->define('post_body', 'str', 'content of the new thread') + ->define('thread_prefix_id', 'uint', 'id of a prefix for the new thread') + ->define('thread_tags', 'str', 'thread tags for the new thread') + ->define('fields', 'array', 'thread fields for the new thread') + ->defineAttachmentHash(); + + $forum = $this->assertViewableForum($params['forum_id']); + if (!$forum->canCreateThread($error)) { + return $this->error($error); + } + + /** @var Creator $creator */ + $creator = $this->service('XF:Thread\Creator', $forum); + $creator->setContent($params['thread_title'], $params['post_body']); + + if ($params['thread_prefix_id'] > 0) { + $creator->setPrefix($params['thread_prefix_id']); + } + + if ($params['thread_tags'] !== '') { + $creator->setTags($params['thread_tags']); + } + + if (count($params['fields']) > 0) { + $creator->setCustomFields($params['fields']); + } + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $creator->setAttachmentHash($attachmentPlugin->getAttachmentTempHash([ + 'node_id' => $forum->node_id, + 'forum_id' => $forum->node_id + ])); + + $creator->checkForSpam(); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $this->assertNotFlooding('post'); + $thread = $creator->save(); + + /** @var \XF\Repository\Thread $threadRepo */ + $threadRepo = $this->repository('XF:Thread'); + $threadRepo->markThreadReadByVisitor($thread, $thread->post_date); + + return $this->actionSingle($thread->thread_id); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + * + * @see Post::actionPutIndex() + */ + public function actionPutIndex(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + return $this->rerouteController('Xfrocks\Api\Controller\Post', 'put-index', [ + '_attachmentContentData' => ['thread_id' => $thread->thread_id], + '_replyCallback' => function ($controller) use ($thread) { + /** @var Post $controller */ + $params = ['thread_id' => $thread->thread_id]; + return $controller->rerouteController('Xfrocks\Api\Controller\Thread', 'get-index', $params); + }, + 'post_id' => $thread->first_post_id + ]); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteIndex(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + $params = $this + ->params() + ->define('reason', 'str', 'reason of the thread removal'); + + if (!$thread->canDelete('soft', $error)) { + return $this->noPermission($error); + } + + /** @var Deleter $deleter */ + $deleter = $this->service('XF:Thread\Deleter', $thread); + $deleter->delete('soft', $params['reason']); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostAttachments() + { + $params = $this + ->params() + ->define('forum_id', 'uint', 'id of the container forum of the target thread') + ->defineAttachmentHash() + ->defineFile('file', 'binary data of the attachment'); + + $forum = $this->assertViewableForum($params['forum_id']); + + $context = [ + 'node_id' => $forum->node_id, + 'forum_id' => $forum->node_id + ]; + + + /** @var \Xfrocks\Api\ControllerPlugin\Attachment $attachmentPlugin */ + $attachmentPlugin = $this->plugin('Xfrocks\Api:Attachment'); + $tempHash = $attachmentPlugin->getAttachmentTempHash($context); + + return $attachmentPlugin->doUpload($tempHash, 'post', $context); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowers(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + $users = []; + if ($thread->canWatch()) { + $visitor = \XF::visitor(); + /** @var \XF\Entity\ThreadWatch|null $watch */ + $watch = $thread->Watch[$visitor->user_id]; + if ($watch !== null) { + $users[] = [ + 'user_id' => $visitor->user_id, + 'username' => $visitor->username, + 'follow' => [ + 'alert' => true, + 'email' => $watch->email_subscribe + ] + ]; + } + } + + $data = [ + 'users' => $users + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostFollowers(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + $params = $this + ->params() + ->define('email', 'bool', 'whether to receive notification as email'); + + if (!$thread->canWatch($error)) { + return $this->noPermission($error); + } + + /** @var ThreadWatch $threadWatchRepo */ + $threadWatchRepo = $this->repository('XF:ThreadWatch'); + $threadWatchRepo->setWatchState( + $thread, + \XF::visitor(), + $params['email'] === true ? 'watch_email' : 'watch_no_email' + ); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteFollowers(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + /** @var ThreadWatch $threadWatchRepo */ + $threadWatchRepo = $this->repository('XF:ThreadWatch'); + $threadWatchRepo->setWatchState($thread, \XF::visitor(), ''); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowed() + { + $params = $this + ->params() + ->define('total', 'uint') + ->definePageNav(); + + $this->assertRegistrationRequired(); + + /** @var \XF\Repository\Thread $threadRepo */ + $threadRepo = $this->repository('XF:Thread'); + $threadFinder = $threadRepo->findThreadsForWatchedList(); + + if ($params['total'] > 0) { + $data = [ + 'threads_total' => $threadFinder->total() + ]; + + return $this->api($data); + } + + $params->limitFinderByPage($threadFinder); + + $context = $this->params()->getTransformContext(); + $context->onTransformedCallbacks[] = function ($context, &$data) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!($source instanceof \XF\Entity\Thread)) { + return; + } + + $data['follow'] = [ + 'alert' => true, + 'email' => $source->Watch[\XF::visitor()->user_id]->email_subscribe + ]; + }; + + $total = $threadFinder->total(); + $threads = $total > 0 ? $this->transformFinderLazily($threadFinder) : []; + + $data = [ + 'threads' => $threads, + 'threads_total' => $total + ]; + + PageNav::addLinksToData($data, $params, $total, 'threads/followed'); + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetNavigation(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + $forum = $thread->Forum; + if ($forum === null) { + throw new \RuntimeException('$thread->Forum === null'); + } + + $breadcrumbs = $forum->getBreadcrumbs(true); + $nodeIds = []; + foreach ($breadcrumbs as $breadcrumb) { + $nodeIds[] = $breadcrumb['node_id']; + } + + /** @var \Xfrocks\Api\ControllerPlugin\Navigation $navigationPlugin */ + $navigationPlugin = $this->plugin('Xfrocks\Api:Navigation'); + $elements = $navigationPlugin->prepareElementsFromIds($nodeIds); + + return $this->api(['elements' => $elements]); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostPollVotes(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + $params = $this + ->params() + ->define('response_id', 'uint', 'the id of the response to vote for') + ->define('response_ids', 'array-uint', 'an array of ids of responses'); + + $poll = $thread->Poll; + if ($poll === null) { + return $this->noPermission(); + } + + if (!$poll->canVote($error)) { + return $this->noPermission(); + } + + /** @var \XF\Service\Poll\Voter $voter */ + $voter = $this->service('XF:Poll\Voter', $poll); + + $responseIds = $params['response_ids']; + if ($params['response_id'] > 0) { + $responseIds[] = $params['response_id']; + } + + $voter->setVotes($responseIds); + if (!$voter->validate($errors)) { + return $this->error($errors); + } + + $voter->save(); + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetPollResults(ParameterBag $params) + { + $thread = $this->assertViewableThread($params->thread_id); + + /** @var Poll|null $poll */ + $poll = $thread->Poll; + if ($poll === null) { + return $this->noPermission(); + } + + if (!$poll->canViewResults($error)) { + return $this->noPermission($error); + } + + $userIds = []; + foreach ($poll->Responses as $pollResponse) { + $userIds = array_merge($userIds, array_keys($pollResponse->voters)); + } + + $users = $this->em()->findByIds('XF:User', $userIds); + + $transformContext = $this->params()->getTransformContext(); + $transformContext->onTransformedCallbacks[] = function ($context, &$data) use ($users) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!($source instanceof PollResponse)) { + return; + } + + $data['voters'] = []; + + foreach (array_keys($source->voters) as $userId) { + if (isset($users[$userId])) { + /** @var \XF\Entity\User $user */ + $user = $users[$userId]; + $data['voters'][] = [ + 'user_id' => $user->user_id, + 'username' => $user->username + ]; + } + } + }; + + $finder = $poll->getRelationFinder('Responses'); + $data = [ + 'results' => $this->transformFinderLazily($finder) + ]; + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionGetNew() + { + $this + ->params() + ->define('forum_id', 'uint') + ->definePageNav(); + + $this->assertRegistrationRequired(); + + /** @var \XF\Repository\Thread $threadRepo */ + $threadRepo = $this->repository('XF:Thread'); + $finder = $threadRepo->findThreadsWithUnreadPosts(); + + return $this->getNewOrRecentResponse('threads_new', $finder); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionGetRecent() + { + $this + ->params() + ->define('forum_id', 'uint') + ->definePageNav(); + + /** @var \XF\Repository\Thread $threadRepo */ + $threadRepo = $this->repository('XF:Thread'); + $finder = $threadRepo->findThreadsWithLatestPosts(); + + return $this->getNewOrRecentResponse('threads_recent', $finder); + } + + /** + * @param array $ids + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionMultiple(array $ids) + { + $threads = []; + if (count($ids) > 0) { + $this->params()->getTransformContext()->setData(true, TransformThread::DYNAMIC_KEY_FORUM_DATA_KEY); + $threads = $this->findAndTransformLazily('XF:Thread', $ids); + } + + return $this->api(['threads' => $threads]); + } + + /** + * @param int $threadId + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionSingle($threadId) + { + $thread = $this->findAndTransformLazily('XF:Thread', intval($threadId), 'requested_thread_not_found'); + return $this->api(['thread' => $thread]); + } + + /** + * @param \XF\Finder\Thread $finder + * @param Params $params + * @return void + * @throws \XF\Mvc\Reply\Exception + */ + protected function applyFilters($finder, Params $params) + { + if ($params['forum_id'] > 0) { + $forum = $this->assertViewableForum($params['forum_id']); + $finder->inForum($forum); + } + + if ($params['creator_user_id'] > 0) { + $finder->where('user_id', $params['creator_user_id']); + } + + if ($this->request()->exists('sticky')) { + $finder->where('sticky', $params['sticky']); + } + + if ($params['thread_prefix_id'] > 0) { + $finder->where('prefix_id', $params['thread_prefix_id']); + } + + // TODO: Add more filters? + } + + /** + * @param string $searchType + * @param Finder $finder + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + protected function getNewOrRecentResponse($searchType, Finder $finder) + { + $params = $this->params(); + + if ($params['forum_id'] > 0) { + $forum = $this->assertViewableForum($params['forum_id']); + + /** @var Node $nodeRepo */ + $nodeRepo = $this->repository('XF:Node'); + $node = $forum->Node; + if ($node !== null) { + $nodeIds = $nodeRepo->findChildren($node, false)->fetch()->keys(); + $nodeIds[] = $forum->node_id; + + $finder->where('node_id', $nodeIds); + } + } + + $finder->limit($this->options()->maximumSearchResults); + $threads = $finder->fetch(); + + $searchResults = []; + /** @var \XF\Entity\Thread $thread */ + foreach ($threads as $thread) { + if ($thread->canView() && !$thread->isIgnored()) { + $searchResults[] = ['thread', $thread->thread_id]; + } + } + + $totalResults = count($searchResults); + if ($totalResults === 0) { + return $this->message(\XF::phrase('no_results_found')); + } + + /** @var \XF\Entity\Search $search */ + $search = $this->em()->create('XF:Search'); + + $search->search_type = $searchType; + $search->search_results = $searchResults; + $search->result_count = $totalResults; + $search->search_order = 'date'; + $search->user_id = \XF::visitor()->user_id; + + $search->query_hash = md5(serialize($search->getNewValues())); + + $search->save(); + + return $this->rerouteController('Xfrocks\Api\Controller\Search', 'get-results', [ + 'search_id' => $search->search_id + ]); + } + + /** + * @param int $forumId + * @param array $extraWith + * @return Forum + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableForum($forumId, array $extraWith = []) + { + $extraWith[] = 'Node.Permissions|' . \XF::visitor()->permission_combination_id; + + /** @var Forum $forum */ + $forum = $this->assertRecordExists('XF:Forum', $forumId, $extraWith, 'requested_forum_not_found'); + + if (!$forum->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $forum; + } + + /** + * @param int $threadId + * @param array $extraWith + * @return \XF\Entity\Thread + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableThread($threadId, array $extraWith = []) + { + $extraWith[] = 'Forum.Node.Permissions|' . \XF::visitor()->permission_combination_id; + + /** @var \XF\Entity\Thread $thread */ + $thread = $this->assertRecordExists('XF:Thread', $threadId, $extraWith, 'requested_thread_not_found'); + + if (!$thread->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $thread; + } +} diff --git a/Controller/Tool.php b/Controller/Tool.php new file mode 100644 index 00000000..b43992cf --- /dev/null +++ b/Controller/Tool.php @@ -0,0 +1,222 @@ +params() + ->define('html', 'str') + ->define('required_externals', 'str') + ->define('timestamp', 'int'); + $link = $this->buildLink('misc/api-chr', null, [ + 'html' => $params['html'], + 'required_externals' => $params['required_externals'], + 'timestamp' => $params['timestamp'], + ]); + + if (\XF::visitor()->user_id > 0) { + /** @var Login $loginPlugin */ + $loginPlugin = $this->plugin('Xfrocks\Api:Login'); + return $loginPlugin->initiate('misc/api-login', $link); + } + + return $this->redirect($link, '', 'permanent'); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply|\XF\Mvc\Reply\Redirect + * @throws \XF\PrintableException + */ + public function actionGetLogin() + { + /** @var Login $loginPlugin */ + $loginPlugin = $this->plugin('Xfrocks\Api:Login'); + return $loginPlugin->initiate('misc/api-login'); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply|\XF\Mvc\Reply\Redirect + * @throws \XF\PrintableException + */ + public function actionGetLogout() + { + /** @var Login $loginPlugin */ + $loginPlugin = $this->plugin('Xfrocks\Api:Login'); + return $loginPlugin->initiate('account/api-logout'); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + */ + public function actionGetWebsubEchoHubChallenge() + { + if (!\XF::$debugMode) { + return $this->noPermission(); + } + + $params = $this->params() + ->define('hub_challenge', 'str'); + + die($params['hub_challenge']); + } + + /** + * @return void + */ + public function actionGetWebsubEchoNone() + { + exit(0); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\PrintableException + */ + public function actionPostCrypt() + { + $params = $this->params() + ->define('algo', 'str', 'Encryption algorithm', Crypt::ALGO_AES_128) + ->define('data', 'str', 'Data string to be encrypted', 'data') + ->define('data_encrypted', 'str', 'Data string to be decrypted') + ->define('key', 'str', 'Key to encrypt/decrypt', 'key'); + + $data = [ + 'algo' => $params['algo'], + 'key' => $params['key'], + 'results' => [ + 'encrypt' => [ + 'input' => $params['data'], + 'output' => Crypt::encrypt($params['data'], $params['algo'], $params['key']) + ], + ], + ]; + + $dataEncrypted = $params['data_encrypted']; + if (strlen($dataEncrypted) > 0) { + $data['results']['decrypt'] = [ + 'input' => $dataEncrypted, + 'output' => Crypt::decrypt($dataEncrypted, $params['algo'], $params['key']) + ]; + } + + return $this->api($data); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionPostLink() + { + $params = $this->params() + ->define('type', 'str', 'Link type (admin, api, or public)', 'public') + ->define('route', 'str', 'Link route', 'index'); + + switch ($params['type']) { + case 'admin': + case 'public': + $link = $this->app->router($params['type'])->buildLink($params['route']); + break; + case 'api': + default: + $link = $this->buildApiLink($params['route']); + break; + } + + return $this->api(['link' => $link]); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionPostOtt() + { + $params = $this->params() + ->define('ttl', 'uint', 'Time to live in seconds'); + + if (!\XF::$debugMode) { + return $this->noPermission(); + } + + $session = $this->session(); + $token = $session->getToken(); + if ($token === null) { + return $this->noPermission(); + } + + $client = $token->Client; + if ($client === null) { + return $this->noPermission(); + } + + return $this->api(['ott' => OneTimeToken::generate($params['ttl'], $client)]); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\PrintableException + */ + public function actionPostPasswordTest() + { + $params = $this->params() + ->define('password', 'str') + ->define('password_algo', 'str') + ->define('decrypt', 'bool'); + + if (!\XF::$debugMode) { + return $this->noPermission(); + } + + if ($params['decrypt'] === false) { + $result = Crypt::encrypt($params['password'], $params['password_algo']); + } else { + $result = Crypt::decrypt($params['password'], $params['password_algo']); + } + + return $this->api(['result' => $result]); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \Exception + */ + public function actionGetParseLink() + { + $params = $this->params() + ->define('link', 'str'); + + /** @var ParseLink $plugin */ + $plugin = $this->plugin('Xfrocks\Api:ParseLink'); + return $plugin->parse($params['link']); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + */ + public function actionPostWebsubEchoHubChallenge() + { + if (!\XF::$debugMode) { + return $this->noPermission(); + } + + $inputRaw = $this->request->getInputRaw(); + \XF\Util\File::log(__METHOD__, $inputRaw); + + exit(0); + } + + protected function getDefaultApiScopeForAction($action) + { + return null; + } +} diff --git a/Controller/User.php b/Controller/User.php new file mode 100644 index 00000000..a3d6e54d --- /dev/null +++ b/Controller/User.php @@ -0,0 +1,1056 @@ +user_id) { + return $this->actionSingle($params->user_id); + } + + $params = $this + ->params() + ->definePageNav(); + + /** @var \XF\Finder\User $finder */ + $finder = $this->finder('XF:User'); + + $finder->isValidUser(); + $finder->order('user_id'); + + $params->limitFinderByPage($finder); + + $total = $finder->total(); + $users = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'users' => $users, + 'users_total' => $total + ]; + + PageNav::addLinksToData($data, $params, $total, 'users'); + + return $this->api($data); + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostIndex() + { + $params = $this + ->params() + ->define('user_email', 'str', 'email of the new user') + ->define('email', 'str', 'email of the new user (deprecated)') + ->define('username', 'str', 'username of the new user') + ->define('password', 'str', 'password of the new user') + ->define('password_algo', 'str', 'algorithm used to encrypt the password parameter') + ->define('user_dob_day', 'uint', 'date of birth (day) of the new user') + ->define('user_dob_month', 'uint', 'date of birth (month) of the new user') + ->define('user_dob_year', 'uint', 'date of birth (year) of the new user') + ->define('fields', 'array', 'user field values') + ->define('client_id', 'str', 'client ID of the Client') + ->define('extra_data', 'str') + ->define('extra_timestamp', 'uint'); + + if (!$this->options()->registrationSetup['enabled']) { + throw $this->exception( + $this->error(\XF::phrase('new_registrations_currently_not_being_accepted')) + ); + } + + // prevent discouraged IP addresses from registering + if ($this->options()->preventDiscouragedRegistration && $this->isDiscouraged()) { + throw $this->exception( + $this->error(\XF::phrase('new_registrations_currently_not_being_accepted')) + ); + } + + $session = $this->session(); + $token = $this->session()->getToken(); + $client = $token !== null ? $token->Client : null; + + if ($client === null) { + /** @var Client $client */ + $client = $this->assertRecordExists( + 'Xfrocks\Api:Client', + $params['client_id'], + [], + 'bdapi_requested_client_not_found' + ); + + $clientSecret = $client->client_secret; + } else { + $clientSecret = $client->client_secret; + } + + $extraData = []; + if ($params['extra_data'] !== '') { + $extraDataDecrypted = Crypt::decryptTypeOne($params['extra_data'], $params['extra_timestamp']); + $extraData = Php::safeUnserialize($extraDataDecrypted); + if (!is_array($extraData)) { + $extraData = []; + } + } + + /** @var \XF\Service\User\Registration $registration */ + $registration = $this->service('XF:User\Registration'); + + $email = $params['user_email']; + if (strlen($email) === 0) { + $email = $params['email']; + } + + $input = [ + 'email' => $email, + 'username' => $params['username'], + 'dob_day' => $params['user_dob_day'], + 'dob_month' => $params['user_dob_month'], + 'dob_year' => $params['user_dob_year'], + 'custom_fields' => $params['fields'] + ]; + + if ($params['password_algo'] !== '') { + $input['password'] = Crypt::decrypt($params['password'], $params['password_algo'], $clientSecret); + } elseif ($params['password'] !== '') { + $input['password'] = $params['password']; + } else { + $registration->setNoPassword(); + } + + $skipEmailConfirmation = false; + if (isset($extraData['user_email']) && $extraData['user_email'] == $input['email']) { + $skipEmailConfirmation = true; + } + + $registration->skipEmailConfirmation($skipEmailConfirmation); + + $registration->setFromInput($input); + $registration->checkForSpam(); + + if (!$registration->validate($errors)) { + return $this->error($errors); + } + + $visitor = \XF::visitor(); + if ($visitor->hasAdminPermission('user')) { + $registration->getUser()->set('user_state', 'valid', [ + 'forceSet' => true + ]); + } + + /** @var \XF\Entity\User $user */ + $user = $registration->save(); + + if ($visitor->user_id == 0) { + $session->changeUser($user); + \XF::setVisitor($user); + } + + if (isset($extraData['external_provider']) + && isset($extraData['external_provider_key']) + && isset($extraData['access_token']) + ) { + /** @var ConnectedAccountProvider|null $provider */ + $provider = $this->em()->find('XF:ConnectedAccountProvider', $extraData['external_provider']); + $handler = $provider !== null ? $provider->getHandler() : null; + if ($handler !== null && $provider !== null) { + $tokenObj = new StdOAuth2Token(); + $tokenObj->setAccessToken($extraData['access_token']); + + $storageState = $handler->getStorageState($provider, $user); + $storageState->storeToken($tokenObj); + + $providerData = $handler->getProviderData($storageState); + + /** @var ConnectedAccount $connectedAccountRepo */ + $connectedAccountRepo = $this->repository('XF:ConnectedAccount'); + $connectedAccountRepo->associateConnectedAccountWithUser($user, $providerData); + } + } + + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + $scopes = $apiServer->getScopeDefaults(); + $accessToken = $apiServer->newAccessToken($user->user_id, $client, $scopes); + $refreshToken = $apiServer->newRefreshToken($user->user_id, $client, $scopes); + + $data = [ + 'user' => $this->transformEntityLazily($user), + 'token' => \Xfrocks\Api\Util\Token::transformLibAccessTokenEntity($accessToken, $refreshToken), + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPutIndex(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + $params = $this + ->params() + ->define('password', 'str', 'data of the new password') + ->define('password_old', 'str', 'data of the existing password') + ->define('password_algo', 'str', 'algorithm used to encrypt the password and password_old parameters') + ->define('user_email', 'str', 'new email of the user') + ->define('username', 'str', 'new username of the user') + ->define('user_title', 'str', 'new custom title of the user') + ->define('primary_group_id', 'uint', 'id of new primary group') + ->define('secondary_group_ids', 'array-uint', 'array of ids of new secondary groups') + ->define('user_dob_day', 'uint', 'new date of birth (day) of the user') + ->define('user_dob_month', 'uint', 'new date of birth (month) of the user') + ->define('user_dob_year', 'uint', 'new date of birth (year) of the user') + ->define('fields', 'array', 'array of values for user fields'); + + $visitor = \XF::visitor(); + $isAdmin = $visitor->hasAdminPermission('user'); + $requiredAuth = 0; + + if ($params['password'] !== '') { + $requiredAuth++; + } + if ($params['user_email'] !== '') { + $requiredAuth++; + } + + if ($requiredAuth > 0) { + $isAuth = false; + if ($isAdmin && $visitor->user_id !== $user->user_id) { + $isAuth = true; + } elseif ($params['password_old'] !== '') { + $userAuth = $user->Auth; + if ($userAuth !== null) { + $passwordOld = $params['password_algo'] === '' ? + $params['password_old'] : + Crypt::decrypt($params['password_old'], $params['password_algo']); + $authHandler = $userAuth->getAuthenticationHandler(); + if ($authHandler !== null + && $authHandler->hasPassword() + && $userAuth->authenticate($passwordOld) + ) { + $isAuth = true; + } + } + } + + if (!$isAuth) { + return $this->error(\XF::phrase('bdapi_slash_users_requires_password_old'), 403); + } + } + + if ($isAdmin) { + $user->setOption('admin_edit', true); + } + + if ($params['password'] !== '') { + /** @var UserAuth $userAuth */ + $userAuth = $user->getRelationOrDefault('Auth'); + + if ($params['password_algo'] !== '') { + $userAuth->setPassword(Crypt::decrypt($params['password'], $params['password_algo'])); + } else { + $userAuth->setPassword($params['password']); + } + + $user->addCascadedSave($userAuth); + } + + if ($params['user_email'] !== '') { + $user->email = $params['user_email']; + $options = $this->options(); + + if ($user->isChanged('email') + && $options->registrationSetup['emailConfirmation'] + ) { + switch ($user->user_state) { + case 'moderated': + case 'email_confirm': + $user->user_state = 'email_confirm'; + break; + default: + $user->user_state = 'email_confirm_edit'; + } + } + } + + if ($params['username'] !== '') { + $user->username = $params['username']; + if ($user->isChanged('username') && !$isAdmin) { + return $this->error(\XF::phrase('bdapi_slash_users_denied_username'), 403); + } + } + + if ($this->request()->exists('user_title')) { + $user->custom_title = $params['user_title']; + if ($user->isChanged('custom_title') + && !$isAdmin + ) { + return $this->error(\XF::phrase('bdapi_slash_users_denied_user_title'), 403); + } + } + + if ($params['primary_group_id'] > 0) { + /** @var UserGroup[] $userGroups */ + $userGroups = $this->finder('XF:UserGroup')->fetch(); + + if (!isset($userGroups[$params['primary_group_id']])) { + return $this->notFound(\XF::phrase('bdapi_requested_user_group_not_found')); + } + + foreach ($params['secondary_group_ids'] as $secondaryGroupId) { + if (!isset($userGroups[$secondaryGroupId])) { + return $this->notFound(\XF::phrase('bdapi_requested_user_group_not_found')); + } + } + + $user->user_group_id = $params['primary_group_id']; + + $secondaryGroupIds = $params['secondary_group_ids']; + $secondaryGroupIds = array_map('intval', $secondaryGroupIds); + $secondaryGroupIds = array_unique($secondaryGroupIds); + sort($secondaryGroupIds, SORT_NUMERIC); + + $zeroKey = array_search(0, $secondaryGroupIds, true); + if ($zeroKey !== false) { + unset($secondaryGroupIds[$zeroKey]); + } + + $user->secondary_group_ids = $secondaryGroupIds; + } + + $userProfile = $user->Profile; + if ($userProfile === null) { + throw new \RuntimeException('$user->Profile === null'); + } + $user->addCascadedSave($userProfile); + + if ($params['user_dob_day'] > 0 && $params['user_dob_month'] > 0 && $params['user_dob_year'] > 0) { + $userProfile->setDob($params['user_dob_day'], $params['user_dob_month'], $params['user_dob_year']); + + $hasExistingDob = false; + if (!!$userProfile->getExistingValue('dob_day') + || !!$userProfile->getExistingValue('dob_month') + || !!$userProfile->getExistingValue('dob_year')) { + $hasExistingDob = true; + } + + if ($hasExistingDob + && ( + $userProfile->isChanged('dob_day') + || $userProfile->isChanged('dob_month') + || $userProfile->isChanged('dob_year') + ) + && !$isAdmin + ) { + return $this->error(\XF::phrase('bdapi_slash_users_denied_dob'), 403); + } + } + + if (count($params['fields']) > 0) { + $inputFilter = $this->app()->inputFilterer(); + $fields = $params['fields']; + + $profileFields = $inputFilter->filterArray($fields, [ + 'about' => 'str', + 'homepage' => 'str', + 'location' => 'str' + ]); + $fields = array_diff($fields, $profileFields); + + $userProfile->bulkSet([ + 'about' => $profileFields['about'], + 'website' => $profileFields['homepage'], + 'location' => $profileFields['location'] + ]); + $userProfile->custom_fields->bulkSet($fields); + } + + $user->preSave(); + + if (!$isAdmin) { + if ($user->isChanged('user_group_id') + || $user->isChanged('secondary_group_ids') + ) { + return $this->error(\XF::phrase('bdapi_slash_users_denied_user_group'), 403); + } + } + + $shouldSendEmailConfirmation = false; + if ($user->isChanged('email') + && in_array($user->user_state, ['email_confirm', 'email_confirm_edit'], true) + ) { + $shouldSendEmailConfirmation = true; + } + + if ($user->hasErrors()) { + return $this->error($user->getErrors()); + } + + $user->save(); + + if ($shouldSendEmailConfirmation) { + /** @var \XF\Service\User\EmailConfirmation $emailConfirmation */ + $emailConfirmation = $this->service('XF:User\EmailConfirmation', $user); + $emailConfirmation->triggerConfirmation(); + } + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPostPassword(ParameterBag $params) + { + $this->params()->markAsDeprecated(); + return $this->actionPutIndex($params); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetDefaultAvatar(ParameterBag $paramBag) + { + $avatarSizeMap = $this->app->container('avatarSizeMap'); + $sizes = implode(', ', array_keys($avatarSizeMap)); + + $params = $this->params() + ->define('size', 'str', "Avatar size ({$sizes})", 'l'); + + $user = $this->assertViewableUser($paramBag->user_id); + + if (!isset($avatarSizeMap[$params['size']])) { + return $this->noPermission(); + } + $size = $avatarSizeMap[$params['size']]; + + $viewParams = [ + 'user' => $user, + 'size' => $size + ]; + + $this->setResponseType('raw'); + return $this->view('Xfrocks\Api\View\User\DefaultAvatar', '', $viewParams); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionGetFields() + { + $app = $this->app; + + /** @var \XF\CustomField\DefinitionSet $definitionSet */ + $definitionSet = $app->container('customFields.users'); + + /** @var Transformer $transformer */ + $transformer = $app->container('api.transformer'); + + $context = $this->params()->getTransformContext(); + $fields = $transformer->transformCustomFieldDefinitionSet($context, $definitionSet, ''); + + $data = [ + 'fields' => $fields + ]; + + return $this->api($data); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionGetFind() + { + $params = $this + ->params() + ->define('username', 'str', 'username to filter') + ->define('user_email', 'str', 'email to filter') + ->define('email', 'str', 'email to filter (deprecated)'); + + /** @var \XF\Finder\User $userFinder */ + $userFinder = $this->finder('XF:User'); + $userFinder->isValidUser(); + + $users = []; + + $email = $params['user_email']; + if (strlen($email) === 0) { + $email = $params['email']; + } + if ($email !== '') { + if (!\XF::visitor()->hasAdminPermission('user')) { + return $this->noPermission(); + } + + /** @var Email $emailValidator */ + $emailValidator = \XF::app()->validator('XF:Email'); + if ($emailValidator->isValid($email)) { + $userFinder->where('email', $email); + $users = $this->transformFinderLazily($userFinder); + } + } elseif (strlen($params['username']) > 0) { + $userFinder->where('username', 'like', $userFinder->escapeLike($params['username'], '?%')); + $users = $this->transformFinderLazily($userFinder->limit(10)); + } + + $data = [ + 'users' => $users + ]; + + return $this->api($data); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetMe() + { + return $this->actionGetIndex($this->buildParamsForVisitor()); + } + + /** + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionPutMe() + { + return $this->actionPutIndex($this->buildParamsForVisitor()); + } + + /** + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostMeAvatar() + { + return $this->actionPostAvatar($this->buildParamsForVisitor()); + } + + /** + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteMeAvatar() + { + return $this->actionDeleteAvatar($this->buildParamsForVisitor()); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetMeFollowers() + { + return $this->actionGetFollowers($this->buildParamsForVisitor()); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetMeFollowings() + { + return $this->actionGetFollowings($this->buildParamsForVisitor()); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetMeGroups() + { + return $this->actionGetGroups($this->buildParamsForVisitor()); + } + + /** + * @param ParameterBag $paramBag + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostReport(ParameterBag $paramBag) + { + $params = $this + ->params() + ->define('message', 'str', 'reason of the report'); + + $user = $this->assertViewableUser($paramBag->user_id); + + if (!$user->canBeReported($error)) { + return $this->noPermission(); + } + + /** @var \XF\Service\Report\Creator $creator */ + $creator = $this->service('XF:Report\Creator', 'user', $user); + $creator->setMessage($params['message']); + + if (!$creator->validate($errors)) { + return $this->error($errors); + } + + $creator->save(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostAvatar(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + $params = $this + ->params() + ->defineFile('avatar', 'binary data of the avatar'); + + if ($user->user_id !== \XF::visitor()->user_id) { + return $this->noPermission(); + } + + if (!$user->canUploadAvatar()) { + return $this->noPermission(); + } + + /** @var \XF\Http\Upload|null $avatarUpload */ + $avatarUpload = $params['avatar']; + if ($avatarUpload === null) { + return $this->error(\XF::phrase('bdapi_requires_upload_x', [ + 'field' => 'avatar' + ]), 400); + } + + /** @var Avatar $avatarService */ + $avatarService = $this->service('XF:User\Avatar', $user); + $avatarService->setImageFromUpload($avatarUpload); + + $avatarService->updateAvatar(); + + return $this->message(\XF::phrase('upload_completed_successfully')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteAvatar(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + if ($user->user_id != \XF::visitor()->user_id) { + return $this->noPermission(); + } + + if (!$user->canUploadAvatar()) { + return $this->noPermission(); + } + + /** @var Avatar $avatar */ + $avatar = $this->service('XF:User\Avatar', $user); + $avatar->deleteAvatar(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowers(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + /** @var \XF\Repository\UserFollow $userFollowRepo */ + $userFollowRepo = $this->repository('XF:UserFollow'); + $userFollowersFinder = $userFollowRepo->findFollowersForProfile($user); + + if ($this->request()->exists('total')) { + $data = [ + 'users_total' => $userFollowersFinder->total() + ]; + + return $this->api($data); + } + + $data = [ + 'users' => [] + ]; + + /** @var UserFollow $userFollow */ + foreach ($userFollowersFinder->fetch() as $userFollow) { + $user = $userFollow->User; + if ($user !== null) { + $data['users'][] = [ + 'user_id' => $user->user_id, + 'username' => $user->username + ]; + } + } + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostFollowers(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + $visitor = \XF::visitor(); + if (!$visitor->canFollowUser($user)) { + return $this->noPermission(); + } + + /** @var Follow $follow */ + $follow = $this->service('XF:User\Follow', $user); + $follow->follow(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteFollowers(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + $visitor = \XF::visitor(); + if (!$visitor->canFollowUser($user)) { + return $this->noPermission(); + } + + /** @var Follow $follow */ + $follow = $this->service('XF:User\Follow', $user); + $follow->unfollow(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowings(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + /** @var \XF\Repository\UserFollow $userFollowRepo */ + $userFollowRepo = $this->repository('XF:UserFollow'); + $userFollowingFinder = $userFollowRepo->findFollowingForProfile($user); + + if ($this->request()->exists('total')) { + $data = [ + 'users_total' => $userFollowingFinder->total() + ]; + + return $this->api($data); + } + + $data = [ + 'users' => [] + ]; + + /** @var UserFollow $userFollow */ + foreach ($userFollowingFinder->fetch() as $userFollow) { + $followUser = $userFollow->FollowUser; + if ($followUser !== null) { + $data['users'][] = [ + 'user_id' => $followUser->user_id, + 'username' => $followUser->username + ]; + } + } + + return $this->api($data); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetIgnored() + { + $this->assertRegistrationRequired(); + + $visitor = \XF::visitor(); + $profile = $visitor->Profile; + + /** @var Finder|null $finder */ + $finder = null; + if ($profile != null && count($profile->ignored) > 0) { + $finder = $this->finder('XF:User') + ->where('user_id', array_keys($profile->ignored)) + ->order('username'); + } + + if ($this->request()->exists('total')) { + $data = [ + 'users_total' => $finder ? $finder->total() : 0 + ]; + + return $this->api($data); + } + + $data = [ + 'users' => [] + ]; + + /** @var \XF\Entity\User $user */ + foreach ($finder->fetch() as $user) { + $data['users'][] = [ + 'user_id' => $user->user_id, + 'username' => $user->username + ]; + } + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostIgnore(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + if (!\XF::visitor()->canIgnoreUser($user, $error)) { + return $this->noPermission($error); + } + + /** @var Ignore $ignore */ + $ignore = $this->service('XF:User\Ignore', $user); + $ignore->ignore(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteIgnore(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + + if (!\XF::visitor()->canIgnoreUser($user, $error)) { + return $this->noPermission($error); + } + + /** @var Ignore $ignore */ + $ignore = $this->service('XF:User\Ignore', $user); + $ignore->unignore(); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetGroups(ParameterBag $params) + { + $hasAdminUserPermission = \XF::visitor()->hasAdminPermission('user'); + if ($params->user_id) { + $user = $this->assertViewableUser($params->user_id); + + if ($user->user_id != \XF::visitor()->user_id && !$hasAdminUserPermission) { + return $this->noPermission(); + } + + $userGroupIds = $user->secondary_group_ids; + $userGroupIds[] = $user->user_group_id; + + $finder = $this->finder('XF:UserGroup'); + $finder->whereIds($userGroupIds); + } else { + if (!$hasAdminUserPermission) { + return $this->noPermission(); + } + + $user = null; + $finder = $this->finder('XF:UserGroup'); + } + + $onTransformedCallbacksRef =& $this->params()->getTransformContext()->onTransformedCallbacks; + $onTransformedCallbacksRef[] = function ($context, array &$data) use ($user) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!$source instanceof UserGroup) { + return; + } + + if ($user !== null) { + $data['is_primary_group'] = $source->user_group_id == $user->user_group_id; + } + }; + + $data = [ + 'user_id' => $user !== null ? $user->user_id : null, + 'user_groups' => $this->transformFinderLazily($finder) + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute + */ + public function actionPostGroups(ParameterBag $params) + { + return $this->rerouteController('Xfrocks\Api\Controller\User', 'put-index', $params); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetTimeline(ParameterBag $params) + { + $user = $this->assertViewableUser($params->user_id); + if (!$user->canViewProfilePosts($error)) { + return $this->noPermission($error); + } + + return $this->rerouteController('Xfrocks\Api:Search', 'user-timeline', $params); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Reroute + */ + public function actionPostTimeline(ParameterBag $params) + { + return $this->rerouteController('Xfrocks\Api:ProfilePost', 'post-index', $params); + } + + /** + * @return \XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetMeTimeline() + { + return $this->actionGetTimeline($this->buildParamsForVisitor()); + } + + /** + * @return \XF\Mvc\Reply\Reroute + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostMeTimeline() + { + return $this->actionPostTimeline($this->buildParamsForVisitor()); + } + + /** + * @param int $userId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + protected function actionSingle($userId) + { + $user = $this->assertViewableUser($userId); + + $data = [ + 'user' => $this->transformEntityLazily($user) + ]; + + return $this->api($data); + } + + protected function getDefaultApiScopeForAction($action) + { + if ($action === 'PostIndex') { + $session = $this->session(); + $token = $session->getToken(); + if ($token === null || $token->client_id === '') { + return null; + } + } + + return parent::getDefaultApiScopeForAction($action); + } + + /** + * @param int $userId + * @param array $extraWith + * @return \XF\Entity\User + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableUser($userId, array $extraWith = []) + { + /** @var \XF\Entity\User $user */ + $user = $this->assertRecordExists('XF:User', $userId, $extraWith, 'requested_user_not_found'); + + return $user; + } + + /** + * @return ParameterBag + * @throws \XF\Mvc\Reply\Exception + */ + protected function buildParamsForVisitor() + { + $this->assertRegistrationRequired(); + + return new ParameterBag(['user_id' => \XF::visitor()->user_id]); + } +} diff --git a/ControllerPlugin/Attachment.php b/ControllerPlugin/Attachment.php new file mode 100644 index 00000000..7728e9ce --- /dev/null +++ b/ControllerPlugin/Attachment.php @@ -0,0 +1,103 @@ +repository('XF:Attachment'); + $handler = $attachRepo->getAttachmentHandler($contentType); + + if ($handler === null) { + throw new PrintableException('Invalid content type.'); + } + + if (!$handler->canManageAttachments($context, $error)) { + throw $this->controller->exception($this->controller->noPermission($error)); + } + + $manipulator = new Manipulator($handler, $attachRepo, $context, $hash); + + if (!$manipulator->canUpload($uploadErrors)) { + throw $this->controller->exception($this->controller->error($uploadErrors)); + } + + /** @var AbstractController $controller */ + $controller = $this->controller; + $params = $controller->params(); + + /** @var \XF\Http\Upload|null $file */ + $file = $params[$formField]; + if ($file === null) { + throw $this->controller->errorException(\XF::phrase('uploaded_file_failed_not_found')); + } + + $attachment = $manipulator->insertAttachmentFromUpload($file, $error); + if (!$attachment) { + throw $this->controller->exception($this->controller->noPermission($error)); + } + + return $controller->api(['attachment' => $controller->transformEntityLazily($attachment)]); + } + + /** + * @param array $contentData + * @return string + */ + public function getAttachmentTempHash(array $contentData = []) + { + /** @var AbstractController $controller */ + $controller = $this->controller; + $params = $controller->params(); + + $prefix = ''; + $inputHash = $params['attachment_hash']; + + if ($inputHash !== '') { + $prefix = sprintf('hash%s', $inputHash); + } elseif (isset($contentData['post_id'])) { + $prefix = sprintf('post%d', $contentData['post_id']); + } elseif (isset($contentData['thread_id'])) { + $prefix = sprintf('thread%d', $contentData['thread_id']); + } elseif (isset($contentData['forum_id'])) { + $prefix = sprintf('node%d', $contentData['forum_id']); + } elseif (isset($contentData['node_id'])) { + $prefix = sprintf('node%d', $contentData['node_id']); + } elseif (isset($contentData['message_id'])) { + $prefix = sprintf('message%d', $contentData['message_id']); + } elseif (isset($contentData['conversation_id'])) { + $prefix = sprintf('conversation%d', $contentData['conversation_id']); + } + + /** @var Session $session */ + $session = $this->session(); + $token = $session->getToken(); + + return md5(sprintf( + 'prefix%s_client%s_visitor%d_salt%s', + $prefix, + $token !== null ? $token->client_id : '', + \XF::visitor()->user_id, + $this->app->config('globalSalt') + )); + } +} diff --git a/ControllerPlugin/Like.php b/ControllerPlugin/Like.php new file mode 100644 index 00000000..79c14b1d --- /dev/null +++ b/ControllerPlugin/Like.php @@ -0,0 +1,88 @@ +getRelationFinder('Reactions'); + $finder->with('ReactionUser'); + + $users = []; + + /** @var \XF\Entity\ReactionContent $reactionContent */ + foreach ($finder->fetch() as $reactionContent) { + $user = $reactionContent->ReactionUser; + + if ($user !== null) { + $users[] = [ + 'user_id' => $user->user_id, + 'username' => $user->username + ]; + } + } + + $data = ['users' => $users]; + + /** @var AbstractController $controller */ + $controller = $this->controller; + + return $controller->api($data); + } + + /** + * @param \XF\Mvc\Entity\Entity $content + * @param bool $insert true to insert, false to delete + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionToggleLike($content, $insert) + { + $requestedReaction = $this->validateReactionAction($content); + + $reactionRepo = $this->getReactionRepo(); + $contentType = $content->getEntityContentType(); + $contentId = $content->getEntityId(); + $reactUser = \XF::visitor(); + + $existingReaction = $reactionRepo->getReactionByContentAndReactionUser( + $contentType, + intval($contentId), + $reactUser->user_id + ); + + if ($insert === ($existingReaction === null)) { + $reactionRepo->reactToContent( + $requestedReaction->reaction_id, + $contentType, + $contentId, + $reactUser, + true + ); + } + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param mixed $key + * @param mixed $type + * @param mixed $default + * @return mixed + */ + public function filter($key, $type = null, $default = null) + { + if ($key === 'reaction_id') { + return 1; + } + + return parent::filter($key, $type, $default); + } +} diff --git a/ControllerPlugin/Login.php b/ControllerPlugin/Login.php new file mode 100644 index 00000000..94c6d3bb --- /dev/null +++ b/ControllerPlugin/Login.php @@ -0,0 +1,140 @@ +controller; + + if ($redirectUri === null) { + $params = $apiController->params() + ->define('redirect_uri', 'str', 'URI to redirect afterwards'); + $redirectUri = $params['redirect_uri']; + } + if ($redirectUri === '') { + return $this->noPermission(); + } + + $session = $apiController->session(); + $token = $session->getToken(); + if ($token === null) { + return $this->noPermission(); + } + + $client = $token->Client; + if ($client === null || !$client->isValidRedirectUri($redirectUri)) { + return $this->noPermission(); + } + + $userId = \XF::visitor()->user_id; + if ($userId < 1) { + return $this->noPermission(); + } + + $timestamp = time() + self::INITIATE_TTL; + $linkParams = array( + '_xfRedirect' => Crypt::encryptTypeOne($redirectUri, $timestamp), + 'timestamp' => $timestamp, + 'user_id' => Crypt::encryptTypeOne(strval($userId), $timestamp) + ); + $redirectTarget = $this->app->router('public')->buildLink($publicLink, null, $linkParams); + + return $this->redirectSilently($redirectTarget); + } + + /** + * @param string $selfLink + * @return \XF\Mvc\Reply\Redirect + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function login($selfLink) + { + $params = $this->filter([ + '_xfRedirect' => 'str', + 'timestamp' => 'uint', + 'user_id' => 'str' + ]); + + $redirect = Crypt::decryptTypeOne($params['_xfRedirect'], $params['timestamp']); + $userId = Crypt::decryptTypeOne($params['user_id'], $params['timestamp']); + /** @var \XF\Entity\User $user */ + $user = $this->assertRecordExists('XF:User', $userId); + if ($user->user_id === \XF::visitor()->user_id) { + return $this->redirectSilently($redirect); + } + + /** @var \XF\ControllerPlugin\Login $loginPlugin */ + $loginPlugin = $this->plugin('XF:Login'); + $loginPlugin->triggerIfTfaConfirmationRequired( + $user, + function () use ($selfLink, $redirect, $userId) { + $timestamp = time() + self::TWO_STEP_TTL; + $comebackParams = array( + '_xfRedirect' => Crypt::encryptTypeOne($redirect, $timestamp), + 'timestamp' => $timestamp, + 'user_id' => Crypt::encryptTypeOne($userId, $timestamp) + ); + $comebackLink = $this->buildLink($selfLink, null, $comebackParams); + + $twoStepParams = ['_xfRedirect' => $comebackLink, 'remember' => 1]; + $twoStepLink = $this->buildLink('login/two-step', null, $twoStepParams); + + throw $this->exception($this->redirectSilently($twoStepLink)); + } + ); + $loginPlugin->completeLogin($user, true); + + return $this->redirectSilently($redirect); + } + + /** + * @return \XF\Mvc\Reply\Redirect + * @throws \XF\PrintableException + */ + public function logout() + { + $params = $this->filter([ + '_xfRedirect' => 'str', + 'timestamp' => 'uint', + 'user_id' => 'str' + ]); + + $redirect = Crypt::decryptTypeOne($params['_xfRedirect'], $params['timestamp']); + $userId = intval(Crypt::decryptTypeOne($params['user_id'], $params['timestamp'])); + if ($userId !== \XF::visitor()->user_id) { + return $this->redirectSilently($this->buildLink('index')); + } + + /** @var \XF\ControllerPlugin\Login $loginPlugin */ + $loginPlugin = $this->plugin('XF:Login'); + $loginPlugin->logoutVisitor(); + + return $this->redirectSilently($redirect); + } + + /** + * @param string $url + * @return \XF\Mvc\Reply\Redirect + */ + public function redirectSilently($url) + { + return $this->redirect($url, '', 'permanent'); + } +} diff --git a/ControllerPlugin/Navigation.php b/ControllerPlugin/Navigation.php new file mode 100644 index 00000000..909a1f22 --- /dev/null +++ b/ControllerPlugin/Navigation.php @@ -0,0 +1,134 @@ +controller; + $context = $controller->params()->getTransformContext(); + $context->onTransformedCallbacks[] = function ($context, &$data) use ($controller) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!($source instanceof \XF\Entity\AbstractNode)) { + return; + } + + $node = $source->Node; + if ($node === null) { + return; + } + + $data['navigation_type'] = strtolower($node->node_type_id); + $data['navigation_id'] = $source->node_id; + $data['navigation_parent_id'] = $node->parent_node_id; + + $data['has_sub_elements'] = $node->hasChildren(); + if ($data['has_sub_elements'] === true) { + if (!isset($data['links'])) { + $data['links'] = []; + } + + $data['links']['sub-elements'] = $controller->buildApiLink( + 'navigation', + null, + ['parent' => $data['navigation_id']] + ); + } + }; + + $elements = []; + foreach ($nodes as $node) { + $element = null; + + switch ($node->node_type_id) { + case 'Category': + /** @var \XF\Entity\Category|null $category */ + $category = $this->em()->instantiateEntity( + 'XF:Category', + ['node_id' => $node->node_id], + ['Node' => $node] + ); + + if ($category !== null) { + $element = $controller->transformEntityLazily($category); + } + break; + case 'Forum': + /** @var \XF\Entity\Forum|null $forum */ + $forum = $this->em()->instantiateEntity( + 'XF:Forum', + ['node_id' => $node->node_id], + ['Node' => $node] + ); + + if ($forum !== null) { + $element = $controller->transformEntityLazily($forum); + } + break; + case 'LinkForum': + /** @var \XF\Entity\LinkForum|null $linkForum */ + $linkForum = $this->em()->instantiateEntity( + 'XF:LinkForum', + ['node_id' => $node->node_id], + ['Node' => $node] + ); + + if ($linkForum !== null) { + $element = $controller->transformEntityLazily($linkForum); + } + break; + case 'Page': + /** @var \XF\Entity\Page|null $page */ + $page = $this->em()->instantiateEntity( + 'XF:Page', + ['node_id' => $node->node_id], + ['Node' => $node] + ); + + if ($page !== null) { + $element = $controller->transformEntityLazily($page); + } + break; + } + + if ($element !== null) { + $elements[] = $element; + } + } + + return $elements; + } + + /** + * @param int[] $nodeIds + * @return array + */ + public function prepareElementsFromIds($nodeIds) + { + $this->em()->findByIds('XF:Node', $nodeIds); + + $nodes = []; + foreach ($nodeIds as $nodeId) { + /** @var \XF\Entity\Node $node */ + $node = $this->em()->instantiateEntity('XF:Node', ['node_id' => $nodeId]); + $nodes[] = $node; + } + + /** @var \XF\Repository\Node $nodeRepo */ + $nodeRepo = $this->repository('XF:Node'); + $nodeRepo->loadNodeTypeDataForNodes($nodes); + + return $this->prepareElements($nodes); + } +} diff --git a/ControllerPlugin/ParseLink.php b/ControllerPlugin/ParseLink.php new file mode 100644 index 00000000..fb25191e --- /dev/null +++ b/ControllerPlugin/ParseLink.php @@ -0,0 +1,183 @@ +controller; + + $request = $this->request($link); + $fullRequestUri = $request->getFullRequestUri(); + + $routed = $this->route($request); + if (is_bool($routed)) { + return $controller->api([ + 'link' => $fullRequestUri, + 'routed' => $routed, + ]); + } + + $rendered = $this->render($request, $routed); + if ($rendered !== null) { + return $rendered; + } + + return $controller->api([ + 'link' => $request->getFullRequestUri(), + 'routed' => \XF::$debugMode ? [ + 'controller' => $routed->getController(), + 'action' => $routed->getAction(), + 'params' => $routed->getParams(), + ] : true, + ]); + } + + /** + * @param string $link + * @return \XF\Http\Request + */ + protected function request($link) + { + $app = $this->app; + + $server = $_SERVER; + $server['REQUEST_URI'] = str_replace($app->options()->boardUrl, '', $link); + return new \XF\Http\Request($app->inputFilterer(), [], [], [], $server); + } + + /** + * @param \XF\Http\Request $request + * @return bool|\XF\Mvc\RouteMatch + * @throws \Exception + */ + protected function route($request) + { + $app = $this->app; + + $dispatcherClass = $app->extendClass('XF\Mvc\Dispatcher'); + /** @var \XF\Mvc\Dispatcher $dispatcher */ + $dispatcher = new $dispatcherClass($app, $request); + $dispatcher->setRouter($app->router('public')); + + return $dispatcher->route($request->getRoutePath()); + } + + /** + * @param \XF\Http\Request $request + * @param \XF\Mvc\RouteMatch $match + * @return \XF\Mvc\Reply\AbstractReply|null + */ + protected function render($request, $match) + { + $params = $match->getParams(); + + switch ($match->getController()) { + case 'XF:Forum': + $nodeId = isset($params['node_id']) ? $params['node_id'] : null; + if ($nodeId === null && isset($params['node_name'])) { + /** @var \XF\Entity\Node|null $node */ + $node = $this->em->findOne('XF:Node', ['node_name' => $params['node_name']]); + if ($node !== null) { + $nodeId = $node->node_id; + } + } + + if ($nodeId !== null) { + $this->request->set('forum_id', $nodeId); + if (isset($params['page'])) { + $this->request->set('page', $params['page']); + } + + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Thread', + 'get-index' + ); + } + + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Navigation', + 'get-index' + ); + case 'XF:GotoPage': + $requestUri = $request->getRequestUri(); + $queryStr = parse_url($requestUri, PHP_URL_QUERY); + if (!is_string($queryStr)) { + break; + } + $queryParams = \GuzzleHttp\Psr7\parse_query($queryStr); + + switch ($match->getAction()) { + case 'post': + if (isset($queryParams['id'])) { + $this->request->set('page_of_post_id', $queryParams['id']); + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Post', + 'get-index' + ); + } + break; + case 'convMessage': + // TODO + break; + } + break; + case 'XF:Member': + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\User', + 'get-index', + isset($params['user_id']) ? ['user_id' => $params['user_id']] : null + ); + case 'XF:Post': + if (isset($params['post_id'])) { + $this->request->set('page_of_post_id', $params['post_id']); + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Post', + 'get-index' + ); + } + break; + case 'XF:Tag': + if (isset($params['tag_url'])) { + /** @var \XF\Entity\Tag|null $tag */ + $tag = $this->em->findOne('XF:Tag', ['tag_url' => $params['tag_url']]); + if ($tag !== null) { + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Tag', + 'get-index', + ['tag_id' => $tag->tag_id] + ); + } + } + break; + case 'XF:Thread': + if (isset($params['thread_id'])) { + $this->request->set('thread_id', $params['thread_id']); + if (isset($params['post_id'])) { + $this->request->set('page_of_post_id', $params['post_id']); + } + if (isset($params['page'])) { + $this->request->set('page', $params['page']); + } + + return $this->controller->rerouteController( + 'Xfrocks\Api\Controller\Post', + 'get-index' + ); + } + break; + } + + return null; + } +} diff --git a/ControllerPlugin/Search.php b/ControllerPlugin/Search.php new file mode 100644 index 00000000..8e111335 --- /dev/null +++ b/ControllerPlugin/Search.php @@ -0,0 +1,71 @@ + $result) { + $grouped[$result[0]][] = $result[1]; + + $dataKey = $result[0] . '-' . $result[1]; + $resultKeyMap[$dataKey] = $resultKey; + } + + $searcher = $this->app->search(); + $controller = $this->controller; + + if (!($controller instanceof AbstractController)) { + throw new \LogicException(sprintf( + 'Controller (%s) must be instanced of (%s)', + get_class($controller), + 'Xfrocks\Api\Controller\AbstractController' + )); + } + + $values = []; + foreach ($grouped as $contentType => $contents) { + $typeHandler = $searcher->handler(strval($contentType)); + $entities = $typeHandler->getContent(array_values($contents), true); + + /** @var Entity $entity */ + foreach ($entities as $entity) { + $dataKey = $contentType . '-' . $entity->getEntityId(); + if (!isset($resultKeyMap[$dataKey])) { + continue; + } + + $transformed = $controller->transformEntityLazily($entity); + $transformed->addCallbackPostTransform(function ($data) use ($contentType, $entity) { + $data['content_type'] = $contentType; + $data['content_id'] = $entity->getEntityId(); + + return $data; + }); + + $values[$resultKeyMap[$dataKey]] = $transformed; + } + } + + $data = []; + foreach ($results as $resultKey => $result) { + if (isset($values[$resultKey])) { + $data[] = $values[$resultKey]; + } + } + + return $data; + } +} diff --git a/Cron/CleanUp.php b/Cron/CleanUp.php new file mode 100644 index 00000000..8362ed9f --- /dev/null +++ b/Cron/CleanUp.php @@ -0,0 +1,44 @@ +repository('Xfrocks\Api:AuthCode'); + $cleanedUp['authCodes'] = $authCodeRepo->deleteExpiredAuthCodes(); + + /** @var RefreshToken $refreshTokenRepo */ + $refreshTokenRepo = $app->repository('Xfrocks\Api:RefreshToken'); + $cleanedUp['refreshTokens'] = $refreshTokenRepo->deleteExpiredRefreshTokens(); + + /** @var Token $tokenRepo */ + $tokenRepo = $app->repository('Xfrocks\Api:Token'); + $cleanedUp['tokens'] = $tokenRepo->deleteExpiredTokens(); + + /** @var Log $logRepo */ + $logRepo = \XF::repository('Xfrocks\Api:Log'); + $cleanedUp['logs'] = $logRepo->pruneExpired(); + + if (\XF::$debugMode) { + $json = json_encode($cleanedUp); + if (is_string($json)) { + File::log(__CLASS__, $json); + } + } + } +} diff --git a/Data/BatchJob.php b/Data/BatchJob.php new file mode 100644 index 00000000..c3741791 --- /dev/null +++ b/Data/BatchJob.php @@ -0,0 +1,117 @@ +app = $app; + $this->method = $method; + $this->params = $params; + $this->uri = $uri; + } + + /** + * @return \XF\Mvc\Reply\AbstractReply + */ + public function execute() + { + $request = $this->buildRequest(); + $dispatcher = $this->buildDispatcher($request); + + $routePath = $request->getRoutePath(); + $match = $dispatcher->route($routePath); + $reply = $dispatcher->dispatchLoop($match); + + return $reply; + } + + /** + * @return string + */ + public function getMethod() + { + return $this->method; + } + + /** + * @return array + */ + public function getParams() + { + return $this->params; + } + + /** + * @return string + */ + public function getUri() + { + return $this->uri; + } + + /** + * @return \XF\Http\Request + */ + protected function buildRequest() + { + $app = $this->app; + $appRequest = $app->request(); + $inputFilterer = $app->inputFilterer(); + + $server = $_SERVER; + $server['REQUEST_METHOD'] = $this->method; + $absoluteUri = $appRequest->convertToAbsoluteUri($this->uri); + $requestUri = str_replace($appRequest->getHostUrl(), '', $absoluteUri); + $server['REQUEST_URI'] = $requestUri; + + $jobRequest = new \XF\Http\Request($inputFilterer, $this->params, [], [], $server); + $jobRequest->set('_isApiJob', true); + + return $jobRequest; + } + + /** + * @param \XF\Http\Request $request + * @return \XF\Mvc\Dispatcher + */ + protected function buildDispatcher($request) + { + $app = $this->app; + try { + $class = $app->extendClass('XF\Mvc\Dispatcher'); + $dispatcher = new $class($app, $request); + + return $dispatcher; + } catch (\Exception $e) { + throw new \RuntimeException('', 0, $e); + } + } +} diff --git a/Data/Modules.php b/Data/Modules.php new file mode 100644 index 00000000..312b08bf --- /dev/null +++ b/Data/Modules.php @@ -0,0 +1,170 @@ + 2018092501, + 'oauth2' => 2016030902, + 'subscription' => 2014092301, + ]; + + public function __construct() + { + $this->addController('Xfrocks:Asset', 'assets'); + $this->addController('Xfrocks:Attachment', 'attachments', ':int/'); + $this->addController('Xfrocks:Batch', 'batch'); + $this->addController('Xfrocks:Category', 'categories', ':int/'); + $this->addController('Xfrocks:Conversation', 'conversations', ':int/'); + $this->addController('Xfrocks:ConversationMessage', 'conversation-messages', ':int/'); + $this->addController('Xfrocks:Index', 'index'); + $this->addController('Xfrocks:Forum', 'forums', ':int/'); + $this->addController('Xfrocks:LostPassword', 'lost-password'); + $this->addController('Xfrocks:Navigation', 'navigation'); + $this->addController('Xfrocks:Notification', 'notifications', ':int/'); + $this->addController('Xfrocks:OAuth2', 'oauth'); + $this->addController('Xfrocks:Page', 'pages', ':int/'); + $this->addController('Xfrocks:ProfilePost', 'profile-posts', ':int/'); + $this->addController('Xfrocks:Post', 'posts', ':int/'); + $this->addController('Xfrocks:Search', 'search', ':int/'); + $this->addController('Xfrocks:Subscription', 'subscriptions', ':int/'); + $this->addController('Xfrocks:Tag', 'tags', ':int/'); + $this->addController('Xfrocks:Thread', 'threads', ':int/'); + $this->addController('Xfrocks:Tool', 'tools'); + $this->addController('Xfrocks:User', 'users', ':int/'); + } + + /** + * @param AbstractController $controller + * @return array + */ + public function getDataForApiIndex($controller) + { + $app = $controller->app(); + $apiRouter = $app->router(Listener::$routerType); + $visitor = \XF::visitor(); + $threadLinkParams = ['data_limit' => $app->options()->discussionsPerPage]; + + $data = [ + 'links' => [ + 'navigation' => $apiRouter->buildLink('navigation', null, ['parent' => 0]), + 'search' => $apiRouter->buildLink('search'), + 'threads/recent' => $apiRouter->buildLink('threads/recent', null, $threadLinkParams), + 'users' => $apiRouter->buildLink('users') + ], + 'post' => [], + ]; + + if ($visitor->user_id > 0) { + $data['links'] += [ + 'conversations' => $apiRouter->buildLink('conversations'), + 'forums/followed' => $apiRouter->buildLink('forums/followed'), + 'notifications' => $apiRouter->buildLink('notifications'), + 'threads/followed' => $apiRouter->buildLink('threads/followed'), + 'threads/new' => $apiRouter->buildLink('threads/new', null, $threadLinkParams), + 'users/ignored' => $apiRouter->buildLink('users/ignored'), + 'users/me' => $apiRouter->buildLink('users', $visitor) + ]; + + if ($visitor->canPostOnProfile()) { + $data['post']['status'] = $apiRouter->buildLink('users/me/timeline'); + } + } + + return $data; + } + + /** + * @return array + */ + final public function getRoutes() + { + return $this->routes; + } + + /** + * @param string $shortName + * @return string + */ + public function getTransformerClass($shortName) + { + return \XF::stringToClass($shortName, 'Xfrocks\Api\%s\Transform\%s'); + } + + /** + * @return array + */ + final public function getVersions() + { + return $this->versions; + } + + /** + * @param string $controller + * @param string $prefix + * @param string $format + * @param null|callable $buildCallback + * @param string $subSection + * @param string $context + * @param string $actionPrefix + * @return void + */ + protected function addController( + $controller, + $prefix, + $format = '', + $buildCallback = null, + $subSection = '', + $context = '', + $actionPrefix = '' + ) { + $this->addRoute($prefix, $subSection, [ + 'format' => $format, + 'build_callback' => $buildCallback, + 'controller' => $controller, + 'context' => $context, + 'action_prefix' => $actionPrefix + ]); + } + + /** + * @param string $prefix + * @param string $subSection + * @param array $route + * @return void + */ + protected function addRoute($prefix, $subSection, $route) + { + if (isset($this->routes[$prefix][$subSection])) { + throw new \InvalidArgumentException(sprintf('Route "%s"."%s" has already existed', $prefix, $subSection)); + } + + $this->routes[$prefix][$subSection] = $route; + } + + /** + * @param string $id + * @param int $version + * @return void + */ + protected function register($id, $version) + { + if (isset($this->versions[$id])) { + throw new \InvalidArgumentException(sprintf('Module "%s" has already been registered', $id)); + } + + $this->versions[$id] = $version; + } +} diff --git a/Data/Param.php b/Data/Param.php new file mode 100644 index 00000000..25a3fc71 --- /dev/null +++ b/Data/Param.php @@ -0,0 +1,36 @@ +type = $type; + $this->description = $description; + } +} diff --git a/Data/Params.php b/Data/Params.php new file mode 100644 index 00000000..bbcd5fa4 --- /dev/null +++ b/Data/Params.php @@ -0,0 +1,482 @@ +controller = $controller; + + $this->defineFieldsFiltering(); + } + + /** + * @param string $key + * @param string|Param|null $type + * @param string|null $description + * @param mixed|null $default + * @return Params + */ + public function define($key, $type = null, $description = null, $default = null) + { + if ($this->defineCompleted) { + throw new \LogicException('All params must be defined together and before the first param parsing.'); + } + if ($key === '') { + throw new \InvalidArgumentException('$key cannot be an empty string'); + } + + if ($type === null || is_string($type)) { + $param = new Param($type, $description); + } else { + $param = $type; + } + + if ($default !== null) { + $param->default = $default; + } + + $this->params[$key] = $param; + return $this; + } + + /** + * @return $this + */ + public function defineAttachmentHash() + { + $this->define('attachment_hash', 'str', 'a unique hash value'); + + return $this; + } + + /** + * @param string $key + * @param string|null $description + * @return Params + */ + public function defineFile($key, $description = null) + { + return $this->define($key, 'file', $description); + } + + /** + * @param string $key + * @param string|null $description + * @return Params + */ + public function defineFiles($key, $description = null) + { + return $this->define($key, 'files', $description); + } + + /** + * @param array[] $choices + * @param string $paramKeyOrder + * @param string $defaultOrder + * @return Params + */ + public function defineOrder(array $choices, $paramKeyOrder = 'order', $defaultOrder = 'natural') + { + $this->orderChoices = $choices; + $this->paramKeyOrder = $paramKeyOrder; + + $param = new Param('str', implode(', ', array_keys($choices))); + $param->default = $defaultOrder; + + return $this->define($paramKeyOrder, $param); + } + + /** + * @param string $paramKeyLimit + * @param string $paramKeyPage + * @return Params + */ + public function definePageNav($paramKeyLimit = 'limit', $paramKeyPage = 'page') + { + $this->paramKeyLimit = $paramKeyLimit; + $this->paramKeyPage = $paramKeyPage; + + return $this->define($paramKeyLimit, 'posint', 'number of items per page') + ->define($paramKeyPage, 'posint', 'page number'); + } + + /** + * @param string $paramKeyExclude + * @param string $paramKeyInclude + * @return Params + */ + public function defineFieldsFiltering( + $paramKeyExclude = 'fields_exclude', + $paramKeyInclude = 'fields_include' + ) { + $this->paramKeyTransformSelectorExclude = $paramKeyExclude; + $this->paramKeyTransformSelectorInclude = $paramKeyInclude; + + return $this->define($paramKeyExclude, 'str', 'coma-separated list of fields to exclude from the response') + ->define($paramKeyInclude, 'str', 'coma-separated list of fields to include in the response'); + } + + /** + * @param string $key + * @return mixed|null + */ + public function filter($key) + { + if (!$this->defineCompleted) { + $this->setDefineCompleted(); + } + + if (!isset($this->params[$key])) { + throw new \LogicException('Unrecognized parameter: ' . $key); + } + $param = $this->params[$key]; + + if ($key === $this->paramKeyLimit) { + list($limit,) = $this->filterLimitAndPage(); + return $limit; + } + if ($key === $this->paramKeyPage) { + list(, $page) = $this->filterLimitAndPage(); + return $page; + } + + if (!isset($this->filtered[$key])) { + $request = $this->controller->request(); + + if ($param->type === 'files' || $param->type === 'file') { + $valueRaw = null; + $value = $request->getFile($key, $param->type === 'files', false); + } else { + $valueRaw = $request->get($key, $param->default); + $filterer = $this->controller->app()->inputFilterer(); + $value = $filterer->filter($valueRaw, $param->type, $param->options); + } + + $this->filtered[$key] = [ + 'default' => $param->default, + 'value' => $value, + 'valueRaw' => $valueRaw + ]; + } + + return $this->filtered[$key]['value']; + } + + /** + * @param string $key + * @return int[] + */ + public function filterCommaSeparatedIds($key) + { + $str = $this->filter($key); + if (!is_string($str)) { + return []; + } + + $ids = preg_split('/[^0-9]/', $str, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($ids)) { + return []; + } + + return array_map('intval', $ids); + } + + /** + * @return array + */ + public function filterLimitAndPage() + { + if ($this->paramKeyLimit === '' || $this->paramKeyPage === '') { + throw new \LogicException('Params::definePageNav() must be called before calling filterLimitAndPage().'); + } + + if (isset($this->filtered[$this->paramKeyLimit]) && isset($this->filtered[$this->paramKeyPage])) { + return [ + $this->filtered[$this->paramKeyLimit]['value'], + $this->filtered[$this->paramKeyPage]['value'] + ]; + } + + $controller = $this->controller; + $options = $controller->options(); + $request = $controller->request(); + $limit = $limitDefault = intval($options->bdApi_paramLimitDefault); + $limitRaw = $request->get($this->paramKeyLimit, ''); + if (strlen($limitRaw) > 0) { + $limit = intval($limitRaw); + + $limitMax = intval($options->bdApi_paramLimitMax); + if ($limitMax > 0) { + $limit = min($limitMax, $limit); + } + } + $limit = max(1, $limit); + $this->filtered[$this->paramKeyLimit] = [ + 'default' => $limitDefault, + 'key' => $this->paramKeyLimit, + 'value' => $limit, + 'valueRaw' => $limitRaw + ]; + + $pageDefault = '1'; + $pageRaw = $request->get($this->paramKeyPage, $pageDefault); + $page = intval($pageRaw); + $pageMax = intval($options->bdApi_paramPageMax); + if ($pageMax > 0) { + $page = min($pageMax, $page); + } + $page = max(1, $page); + $this->filtered[$this->paramKeyPage] = [ + 'default' => $pageDefault, + 'key' => $this->paramKeyPage, + 'max' => $pageMax, + 'value' => $page, + 'valueRaw' => $pageRaw + ]; + + return [$limit, $page]; + } + + /** + * @return array + */ + public function filterTransformSelector() + { + if ($this->paramKeyTransformSelectorExclude === '' || $this->paramKeyTransformSelectorInclude === '') { + throw new \LogicException('Params::defineFieldsFiltering() must be called before calling filterTransformSelector().'); + } + + return [ + $this->filter($this->paramKeyTransformSelectorExclude), + $this->filter($this->paramKeyTransformSelectorInclude), + ]; + } + + /** + * @return AbstractController + */ + public function getController() + { + return $this->controller; + } + + /** + * @param string $key + * @return array|null + */ + public function getFiltered($key) + { + if (!isset($this->filtered[$key])) { + return null; + } + return $this->filtered[$key]; + } + + /** + * @return array|null + */ + public function getFilteredLimit() + { + return $this->getFiltered($this->paramKeyLimit); + } + + /** + * @return array|null + */ + public function getFilteredPage() + { + return $this->getFiltered($this->paramKeyPage); + } + + /** + * @return array + */ + public function getFilteredValues() + { + $values = []; + foreach ($this->filtered as $key => $filtered) { + $values[$key] = $filtered['value']; + } + return $values; + } + + /** + * @return TransformContext + */ + public function getTransformContext() + { + if ($this->transformContext === null) { + $selector = new Selector(); + list($exclude, $include) = $this->filterTransformSelector(); + $selector->parseRules($exclude, $include); + + $this->transformContext = new TransformContext(null, null, $selector); + } + + return $this->transformContext; + } + + /** + * @param \XF\Mvc\Entity\Finder $finder + * @return int + */ + public function limitFinderByPage($finder) + { + list($limit, $page) = $this->filterLimitAndPage(); + $finder->limitByPage($page, $limit); + + return $page; + } + + /** + * @return void + */ + public function markAsDeprecated() + { + $this->isDeprecated = true; + } + + /** + * @param mixed $offset + * @return bool + */ + public function offsetExists($offset) + { + return isset($this->params[$offset]); + } + + /** + * @param mixed $offset + * @return mixed|null + */ + public function offsetGet($offset) + { + return $this->filter($offset); + } + + /** + * @param mixed $offset + * @param mixed $value + * @return void + */ + public function offsetSet($offset, $value) + { + if (!isset($this->filtered[$offset])) { + throw new \LogicException('offsetSet must be called after offsetGet: ' . $offset); + } + $this->filtered[$offset]['value'] = $value; + } + + /** + * @param mixed $offset + * @return void + */ + public function offsetUnset($offset) + { + if (!isset($this->params[$offset])) { + throw new \LogicException('Unrecognized parameter: ' . $offset); + } + unset($this->filtered[$offset]); + } + + /** + * @param \XF\Mvc\Entity\Finder $finder + * @return array|false + */ + public function sortFinder($finder) + { + if ($this->paramKeyOrder === '') { + throw new \LogicException('Params::defineOrder() must be called before calling sortFinder().'); + } + + $order = $this->offsetGet($this->paramKeyOrder); + if ($order === '' || !isset($this->orderChoices[$order])) { + return false; + } + + $orderChoice = $this->orderChoices[$order]; + + if (count($orderChoice) >= 2) { + $finder->order($orderChoice[0], $orderChoice[1]); + } + + return $orderChoice; + } + + /** + * @return void + */ + protected function setDefineCompleted() + { + $this->defineCompleted = true; + } +} diff --git a/DevHelper/Admin/Controller/Entity.php b/DevHelper/Admin/Controller/Entity.php new file mode 100644 index 00000000..5ead9792 --- /dev/null +++ b/DevHelper/Admin/Controller/Entity.php @@ -0,0 +1,762 @@ +filterPage(); + $perPage = $this->getPerPage(); + + list($finder, $filters) = $this->entityListData(); + + $finder->limitByPage($page, $perPage); + $total = $finder->total(); + + $viewParams = [ + 'entities' => $finder->fetch(), + + 'filters' => $filters, + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total + ]; + + return $this->getViewReply('list', $viewParams); + } + + /** + * @return \XF\Mvc\Reply\View + * @throws \Exception + */ + public function actionAdd() + { + if (!$this->supportsAdding()) { + return $this->noPermission(); + } + + return $this->entityAddEdit($this->createEntity()); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\View|\XF\Mvc\Reply\Redirect + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionDelete(ParameterBag $params) + { + if (!$this->supportsDeleting()) { + return $this->noPermission(); + } + + $entityId = $this->getEntityIdFromParams($params); + $entity = $this->assertEntityExists($entityId); + + if ($this->isPost()) { + $entity->delete(); + + return $this->redirect($this->buildLink($this->getRoutePrefix())); + } + + $viewParams = [ + 'entity' => $entity, + 'entityLabel' => $this->getEntityLabel($entity) + ]; + + return $this->getViewReply('delete', $viewParams); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + public function actionEdit(ParameterBag $params) + { + if (!$this->supportsEditing()) { + return $this->noPermission(); + } + + $entityId = $this->getEntityIdFromParams($params); + $entity = $this->assertEntityExists($entityId); + return $this->entityAddEdit($entity); + } + + /** + * @return \XF\Mvc\Reply\Error|\XF\Mvc\Reply\Redirect + * @throws \Exception + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionSave() + { + $this->assertPostOnly(); + + $entityId = $this->filter('entity_id', 'str'); + if ($entityId !== '') { + $entity = $this->assertEntityExists($entityId); + } else { + $entity = $this->createEntity(); + } + + $this->entitySaveProcess($entity)->run(); + + return $this->redirect($this->buildLink($this->getRoutePrefix())); + } + + /** + * @param MvcEntity $entity + * @param string $columnName + * @return string|object|null + */ + public function getEntityColumnLabel($entity, $columnName) + { + /** @var mixed $unknownEntity */ + $unknownEntity = $entity; + $callback = [$unknownEntity, 'getEntityColumnLabel']; + if (!is_callable($callback)) { + $shortName = $entity->structure()->shortName; + throw new \InvalidArgumentException("Entity {$shortName} does not implement {$callback[1]}"); + } + + return call_user_func($callback, $columnName); + } + + /** + * @param MvcEntity $entity + * @return string + */ + public function getEntityExplain($entity) + { + return ''; + } + + /** + * @param MvcEntity $entity + * @return string + */ + public function getEntityHint($entity) + { + $structure = $entity->structure(); + if (isset($structure->columns['display_order'])) { + return sprintf('%s: %d', \XF::phrase('display_order'), $entity->get('display_order')); + } + + return ''; + } + + /** + * @param MvcEntity $entity + * @return mixed + */ + public function getEntityLabel($entity) + { + /** @var mixed $unknownEntity */ + $unknownEntity = $entity; + $callback = [$unknownEntity, 'getEntityLabel']; + if (!is_callable($callback)) { + $shortName = $entity->structure()->shortName; + throw new \InvalidArgumentException("Entity {$shortName} does not implement {$callback[1]}"); + } + + return call_user_func($callback); + } + + /** + * @param int $entityId + * @return MvcEntity + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertEntityExists($entityId) + { + return $this->assertRecordExists($this->getShortName(), $entityId); + } + + /** + * @return MvcEntity + */ + protected function createEntity() + { + return $this->em()->create($this->getShortName()); + } + + /** + * @param MvcEntity $entity + * @return \XF\Mvc\Reply\View + * @throws \Exception + */ + protected function entityAddEdit($entity) + { + $viewParams = [ + 'entity' => $entity, + 'columns' => [], + ]; + + $structure = $entity->structure(); + $viewParams['columns'] = $this->entityGetMetadataForColumns($entity); + + foreach ($structure->relations as $relationKey => $relation) { + if (!isset($relation['entity']) || + !isset($relation['type']) || + $relation['type'] !== MvcEntity::TO_ONE || + !isset($relation['primary']) || + !isset($relation['conditions'])) { + continue; + } + + $columnName = ''; + $relationConditions = $relation['conditions']; + if (is_string($relationConditions)) { + $columnName = $relationConditions; + } elseif (is_array($relationConditions)) { + if (count($relationConditions) === 1) { + $relationCondition = reset($relationConditions); + if (count($relationCondition) === 3 && + $relationCondition[1] === '=' && + preg_match('/\$(.+)$/', $relationCondition[2], $matches) === 1 + ) { + $columnName = $matches[1]; + } + } + } + if ($columnName === '' || !isset($viewParams['columns'][$columnName])) { + continue; + } + $columnViewParamRef = &$viewParams['columns'][$columnName]; + list ($relationTag, $relationTagOptions) = $this->entityAddEditRelationColumn( + $entity, + $columnViewParamRef['_structureData'], + $relationKey, + $relation + ); + + if ($relationTag !== null) { + $columnViewParamRef['tag'] = $relationTag; + $columnViewParamRef['tagOptions'] = $relationTagOptions; + } + } + + return $this->getViewReply('edit', $viewParams); + } + + /** + * @param MvcEntity $entity + * @param array $column + * @param string $relationKey + * @param array $relation + * @return array + */ + protected function entityAddEditRelationColumn($entity, array $column, $relationKey, array $relation) + { + $tag = null; + $tagOptions = []; + switch ($relation['entity']) { + case 'XF:Forum': + $tag = 'select'; + /** @var \XF\Repository\Node $nodeRepo */ + $nodeRepo = $entity->repository('XF:Node'); + $tagOptions['choices'] = $nodeRepo->getNodeOptionsData(false, ['Forum']); + break; + case 'XF:User': + $tag = 'username'; + /** @var \XF\Entity\User|null $user */ + $user = $entity->getRelation($relationKey); + $tagOptions['username'] = $user !== null ? $user->username : ''; + break; + default: + if (strpos($relation['entity'], $this->getPrefixForClasses()) === 0) { + $choices = []; + + /** @var MvcEntity $relationEntity */ + foreach ($this->finder($relation['entity'])->fetch() as $relationEntity) { + $choices[] = [ + 'value' => $relationEntity->getEntityId(), + 'label' => $this->getEntityLabel($relationEntity) + ]; + } + + $tag = 'select'; + $tagOptions['choices'] = $choices; + } + } + + if ($tag === 'select') { + if (isset($tagOptions['choices']) && + (!isset($column['required']) || $column['required'] === false) + ) { + array_unshift($tagOptions['choices'], [ + 'value' => 0, + 'label' => '', + ]); + } + } + + return [$tag, $tagOptions]; + } + + /** + * @param \XF\Mvc\Entity\Entity $entity + * @param string $columnName + * @param array $column + * @return array|null + * @throws \Exception + */ + protected function entityGetMetadataForColumn($entity, $columnName, array $column) + { + $columnTag = null; + $columnTagOptions = []; + $columnFilter = null; + $requiresLabel = true; + + if (!$entity->exists()) { + if (isset($column['default'])) { + $entity->set($columnName, $column['default']); + } + + if ($this->request->exists($columnName)) { + $input = $this->filter(['filters' => [$columnName => 'str']]); + if ($input['filters'][$columnName] !== '') { + $entity->set($columnName, $this->filter($columnName, $input['filters'][$columnName])); + $requiresLabel = false; + } + } + } else { + if (isset($column['writeOnce']) && $column['writeOnce'] === true) { + // do not render row for write once column, new value won't be accepted anyway + return null; + } + } + + $columnLabel = $this->getEntityColumnLabel($entity, $columnName); + if ($requiresLabel && $columnLabel === null) { + return null; + } + + switch ($column['type']) { + case MvcEntity::BOOL: + $columnTag = 'radio'; + $columnTagOptions = [ + 'choices' => [ + ['value' => 1, 'label' => \XF::phrase('yes')], + ['value' => 0, 'label' => \XF::phrase('no')], + ] + ]; + $columnFilter = 'bool'; + break; + case MvcEntity::INT: + $columnTag = 'number-box'; + $columnFilter = 'int'; + break; + case MvcEntity::UINT: + $columnTag = 'number-box'; + $columnTagOptions['min'] = 0; + $columnFilter = 'uint'; + break; + case MvcEntity::STR: + if (isset($column['allowedValues'])) { + $choices = []; + foreach ($column['allowedValues'] as $allowedValue) { + $label = $allowedValue; + if (is_object($columnLabel) && $columnLabel instanceof \XF\Phrase) { + $labelPhraseName = $columnLabel->getName() . '_' . + preg_replace('/[^a-z]+/i', '_', $allowedValue); + $label = \XF::phraseDeferred($labelPhraseName); + } + + $choices[] = [ + 'value' => $allowedValue, + 'label' => $label + ]; + } + + $columnTag = 'select'; + $columnTagOptions = ['choices' => $choices]; + } elseif (isset($column['maxLength']) && $column['maxLength'] <= 255) { + $columnTag = 'text-box'; + } else { + $columnTag = 'text-area'; + } + $columnFilter = 'str'; + break; + } + + if ($columnTag === null || $columnFilter === null) { + if (isset($column['inputFilter']) && isset($column['macroTemplate'])) { + $columnTag = 'custom'; + $columnFilter = $column['inputFilter']; + } + } + + if ($columnTag === null || $columnFilter === null) { + if (\XF::$debugMode) { + if ($columnTag === null) { + throw new \Exception( + "Cannot render column {$columnName}, " . + "consider putting \`macroTemplate\` in getStructure for custom rendering." + ); + } + + if ($columnFilter === null) { + throw new \Exception( + "Cannot detect filter data type for column {$columnName}, " . + "consider putting \`inputFilter\` in getStructure to continue." + ); + } + } + + return null; + } + + return [ + 'filter' => $columnFilter, + 'label' => $columnLabel, + 'tag' => $columnTag, + 'tagOptions' => $columnTagOptions, + ]; + } + + /** + * @param \XF\Mvc\Entity\Entity $entity + * @return array + * @throws \Exception + */ + protected function entityGetMetadataForColumns($entity) + { + $columns = []; + $structure = $entity->structure(); + + $getterColumns = []; + foreach ($structure->getters as $getterKey => $getterCacheable) { + if (!$getterCacheable) { + continue; + } + + $columnLabel = $this->getEntityColumnLabel($entity, $getterKey); + if ($columnLabel === null) { + continue; + } + + $value = $entity->get($getterKey); + if (!($value instanceof \XF\Phrase)) { + continue; + } + + $getterColumns[$getterKey] = [ + 'isGetter' => true, + 'isNotValue' => true, + 'isPhrase' => true, + 'type' => MvcEntity::STR + ]; + } + + $structureColumns = array_merge($getterColumns, $structure->columns); + foreach ($structureColumns as $columnName => $column) { + $metadata = $this->entityGetMetadataForColumn($entity, $columnName, $column); + if (!is_array($metadata)) { + continue; + } + + $columns[$columnName] = $metadata; + $columns[$columnName] += [ + '_structureData' => $column, + 'name' => sprintf('values[%s]', $columnName), + 'value' => $entity->get($columnName), + ]; + } + + return $columns; + } + + /** + * @return array + */ + final protected function entityListData() + { + $shortName = $this->getShortName(); + $finder = $this->finder($shortName); + $filters = ['pageNavParams' => []]; + + /** @var mixed $unknownFinder */ + $unknownFinder = $finder; + $entityDoXfFilter = [$unknownFinder, 'entityDoXfFilter']; + if (is_callable($entityDoXfFilter)) { + $filter = $this->filter('_xfFilter', ['text' => 'str', 'prefix' => 'bool']); + if (strlen($filter['text']) > 0) { + call_user_func($entityDoXfFilter, $filter['text'], $filter['prefix']); + $filters['_xfFilter'] = $filter['text']; + } + } + + $entityDoListData = [$unknownFinder, 'entityDoListData']; + if (is_callable($entityDoListData)) { + $filters = call_user_func($entityDoListData, $this, $filters); + } else { + $structure = $this->em()->getEntityStructure($shortName); + if (isset($structure->columns['display_order'])) { + $finder->setDefaultOrder('display_order'); + } + } + + return [$finder, $filters]; + } + + /** + * @param \XF\Mvc\Entity\Entity $entity + * @return FormAction + * @throws \Exception + */ + protected function entitySaveProcess($entity) + { + $filters = []; + $columns = $this->entityGetMetadataForColumns($entity); + foreach ($columns as $columnName => $metadata) { + if (isset($metadata['_structureData']['isNotValue']) + && $metadata['_structureData']['isNotValue'] === true + ) { + continue; + } + + $filters[$columnName] = $metadata['filter']; + } + + $form = $this->formAction(); + $input = $this->filter(['values' => $filters]); + $form->basicEntitySave($entity, $input['values']); + + $form->setup(function (FormAction $form) use ($columns, $entity) { + $input = $this->filter([ + 'hidden_columns' => 'array-str', + 'hidden_values' => 'array-str', + 'values' => 'array', + ]); + + foreach ($input['hidden_columns'] as $columnName) { + $entity->set( + $columnName, + isset($input['hidden_values'][$columnName]) ? $input['hidden_values'][$columnName] : '' + ); + } + + foreach ($columns as $columnName => $metadata) { + if (!isset($input['values'][$columnName])) { + continue; + } + + if (isset($metadata['_structureData']['isPhrase']) && + $metadata['_structureData']['isPhrase'] === true + ) { + /** @var mixed $unknownEntity */ + $unknownEntity = $entity; + $callable = [$unknownEntity, 'getMasterPhrase']; + if (is_callable($callable)) { + /** @var \XF\Entity\Phrase $masterPhrase */ + $masterPhrase = call_user_func($callable, $columnName); + $masterPhrase->phrase_text = $input['values'][$columnName]; + $entity->addCascadedSave($masterPhrase); + } + } + } + }); + + $form->setup(function (FormAction $form) use ($entity) { + $input = $this->filter([ + 'username_columns' => 'array-str', + 'username_values' => 'array-str', + ]); + + foreach ($input['username_columns'] as $columnName) { + $userId = 0; + + if (isset($input['username_values'][$columnName])) { + /** @var \XF\Repository\User $userRepo */ + $userRepo = $this->repository('XF:User'); + /** @var \XF\Entity\User|null $user */ + $user = $userRepo->getUserByNameOrEmail($input['username_values'][$columnName]); + if ($user === null) { + $form->logError(\XF::phrase('requested_user_not_found')); + } else { + $userId = $user->user_id; + } + } + + $entity->set($columnName, $userId); + } + }); + + return $form; + } + + /** + * @param ParameterBag $params + * @return int + */ + protected function getEntityIdFromParams(ParameterBag $params) + { + $structure = $this->em()->getEntityStructure($this->getShortName()); + if (is_string($structure->primaryKey)) { + return $params->get($structure->primaryKey); + } + + return 0; + } + + /** + * @return int + */ + protected function getPerPage() + { + return 20; + } + + /** + * @return array + */ + protected function getViewLinks() + { + $routePrefix = $this->getRoutePrefix(); + $links = [ + 'index' => $routePrefix, + 'save' => sprintf('%s/save', $routePrefix) + ]; + + if ($this->supportsAdding()) { + $links['add'] = sprintf('%s/add', $routePrefix); + } + + if ($this->supportsDeleting()) { + $links['delete'] = sprintf('%s/delete', $routePrefix); + } + + if ($this->supportsEditing()) { + $links['edit'] = sprintf('%s/edit', $routePrefix); + } + + if ($this->supportsViewing()) { + $links['view'] = sprintf('%s/view', $routePrefix); + } + + if ($this->supportsXfFilter()) { + $links['quickFilter'] = $routePrefix; + } + + return $links; + } + + /** + * @return array + */ + protected function getViewPhrases() + { + $prefix = $this->getPrefixForPhrases(); + + $phrases = []; + foreach ([ + 'add', + 'edit', + 'entities', + 'entity', + ] as $partial) { + $phrases[$partial] = \XF::phrase(sprintf('%s_%s', $prefix, $partial)); + } + + return $phrases; + } + + /** + * @param string $action + * @param array $viewParams + * @return \XF\Mvc\Reply\View + */ + protected function getViewReply($action, array $viewParams) + { + $viewClass = sprintf('%s\Entity%s', $this->getShortName(), ucwords($action)); + $templateTitle = sprintf('%s_entity_%s', $this->getPrefixForTemplates(), strtolower($action)); + + $viewParams['controller'] = $this; + $viewParams['links'] = $this->getViewLinks(); + $viewParams['phrases'] = $this->getViewPhrases(); + + return $this->view($viewClass, $templateTitle, $viewParams); + } + + /** + * @return bool + */ + protected function supportsAdding() + { + return true; + } + + /** + * @return bool + */ + protected function supportsDeleting() + { + return true; + } + + /** + * @return bool + */ + protected function supportsEditing() + { + return true; + } + + /** + * @return bool + */ + protected function supportsViewing() + { + return false; + } + + /** + * @return bool + */ + protected function supportsXfFilter() + { + /** @var mixed $unknownFinder */ + $unknownFinder = $this->finder($this->getShortName()); + return is_callable([$unknownFinder, 'entityDoXfFilter']); + } + + /** + * @return string + */ + abstract protected function getShortName(); + + /** + * @return string + */ + abstract protected function getPrefixForClasses(); + + /** + * @return string + */ + abstract protected function getPrefixForPhrases(); + + /** + * @return string + */ + abstract protected function getPrefixForTemplates(); + + /** + * @return string + */ + abstract protected function getRoutePrefix(); +} diff --git a/Entity/AuthCode.php b/Entity/AuthCode.php new file mode 100644 index 00000000..c8127abb --- /dev/null +++ b/Entity/AuthCode.php @@ -0,0 +1,103 @@ +auth_code_text; + } + + /** + * @param string $columnName + * @return \XF\Phrase|null + */ + public function getEntityColumnLabel($columnName) + { + switch ($columnName) { + case 'auth_code_text': + case 'client_id': + case 'expire_date': + case 'redirect_uri': + case 'scope': + return \XF::phrase('bdapi_' . $columnName); + case 'user_id': + return \XF::phrase('user_name'); + } + + return null; + } + + /** + * @return string + */ + public function getEntityLabel() + { + return $this->auth_code_text; + } + + public static function getStructure(Structure $structure) + { + /** @var Server $apiServer */ + $apiServer = \XF::app()->container('api.server'); + + $structure->table = 'xf_bdapi_auth_code'; + $structure->shortName = 'Xfrocks\Api:AuthCode'; + $structure->primaryKey = 'auth_code_id'; + $structure->columns = [ + 'auth_code_id' => ['type' => self::UINT, 'autoIncrement' => true], + 'auth_code_text' => [ + 'type' => self::STR, + 'maxLength' => 255, + 'default' => $apiServer->generateSecureKey(), + 'writeOnce' => true, + ], + 'client_id' => ['type' => self::STR, 'maxLength' => 255, 'required' => true], + 'user_id' => ['type' => self::UINT, 'default' => \XF::visitor()->user_id], + 'expire_date' => ['type' => self::UINT, 'default' => \XF::$time + $apiServer->getOptionAuthCodeTTL()], + 'redirect_uri' => ['type' => self::STR, 'required' => true], + ]; + $structure->relations = [ + 'User' => [ + 'entity' => 'XF:User', + 'type' => self::TO_ONE, + 'conditions' => 'user_id', + 'primary' => true + ], + 'Client' => [ + 'entity' => 'Xfrocks\Api:Client', + 'type' => self::TO_ONE, + 'conditions' => 'client_id', + 'primary' => true + ] + ]; + + self::addDefaultTokenElements($structure); + + return $structure; + } +} diff --git a/Entity/Client.php b/Entity/Client.php new file mode 100644 index 00000000..78614194 --- /dev/null +++ b/Entity/Client.php @@ -0,0 +1,175 @@ +user_id; + return $visitorUserId > 0 && $visitorUserId === $this->user_id; + } + + /** + * @param string $redirectUri + * @return bool + */ + public function isValidRedirectUri($redirectUri) + { + if ($redirectUri === $this->redirect_uri) { + return true; + } + + if (strpos($redirectUri, $this->app()->options()->boardUrl) === 0) { + return true; + } + + if (!isset($this->options['whitelisted_domains'])) { + return false; + } + + $parsed = parse_url($redirectUri); + if (!is_array($parsed) || !isset($parsed['scheme']) || !isset($parsed['host'])) { + return false; + } + + $domains = explode("\n", $this->options['whitelisted_domains']); + foreach ($domains as $domain) { + if ($domain === '') { + continue; + } + + $pattern = '#^'; + for ($i = 0, $l = utf8_strlen($domain); $i < $l; $i++) { + $char = utf8_substr($domain, $i, 1); + if ($char === '*') { + $pattern .= '.+'; + } else { + $pattern .= preg_quote($char, '#'); + } + } + $pattern .= '$#'; + if (preg_match($pattern, $parsed['host']) === 1) { + return true; + } + } + + return false; + } + + /** + * @param array $options + * @return void + */ + public function setClientOptions(array $options) + { + /** @var array|null $existing */ + $existing = $this->options; + $this->options = Arr::mapMerge($existing !== null ? $existing : [], $options); + } + + /** + * @param string $columnName + * @return \XF\Phrase|null + */ + public function getEntityColumnLabel($columnName) + { + switch ($columnName) { + case 'client_id': + case 'client_secret': + case 'redirect_uri': + return \XF::phrase('bdapi_' . $columnName); + case 'name': + case 'description': + return \XF::phrase('bdapi_client_' . $columnName); + case 'user_id': + return \XF::phrase('user_name'); + } + + return null; + } + + /** + * @return string + */ + public function getEntityLabel() + { + return $this->name; + } + + public static function getStructure(Structure $structure) + { + $structure->table = 'xf_bdapi_client'; + $structure->shortName = 'Xfrocks\Api:Client'; + $structure->primaryKey = 'client_id'; + $structure->columns = [ + 'name' => ['type' => self::STR, 'maxLength' => 255, 'required' => true], + 'description' => ['type' => self::STR, 'required' => true], + 'user_id' => ['type' => self::UINT, 'default' => \XF::visitor()->user_id], + 'redirect_uri' => ['type' => self::STR, 'required' => true], + 'client_id' => [ + 'type' => self::STR, + 'default' => \XF::generateRandomString(10), + 'maxLength' => 255, + 'unique' => true, + 'writeOnce' => true, + ], + 'client_secret' => [ + 'type' => self::STR, + 'default' => \XF::generateRandomString(15), + 'maxLength' => 255, + ], + 'options' => ['type' => self::SERIALIZED_ARRAY] + ]; + $structure->relations = [ + 'User' => [ + 'entity' => 'XF:User', + 'type' => self::TO_ONE, + 'conditions' => 'user_id', + 'primary' => true + ] + ]; + + return $structure; + } + + /** + * @return void + */ + protected function _postDelete() + { + $db = $this->db(); + + $db->delete('xf_bdapi_auth_code', 'client_id = ?', $this->client_id); + $db->delete('xf_bdapi_refresh_token', 'client_id = ?', $this->client_id); + $db->delete('xf_bdapi_token', 'client_id = ?', $this->client_id); + $db->delete('xf_bdapi_user_scope', 'client_id = ?', $this->client_id); + + $this + ->app() + ->jobManager() + ->enqueueUnique('bdapi_' . $this->client_id, 'Xfrocks\Api\Job\ClientDelete', [ + 'clientId' => $this->client_id + ]); + } +} diff --git a/Entity/Log.php b/Entity/Log.php new file mode 100644 index 00000000..89357983 --- /dev/null +++ b/Entity/Log.php @@ -0,0 +1,72 @@ +request_uri; + } + + public static function getStructure(Structure $structure) + { + $structure->table = 'xf_bdapi_log'; + $structure->primaryKey = 'log_id'; + $structure->shortName = 'Xfrocks\Api:Log'; + $structure->columns = [ + 'log_id' => ['type' => self::UINT, 'nullable' => true, 'autoIncrement' => true], + 'client_id' => ['type' => self::STR, 'maxLength' => 255, 'default' => ''], + 'user_id' => ['type' => self::UINT, 'required' => true], + 'ip_address' => ['type' => self::STR, 'maxLength' => 50, 'required' => true], + 'request_date' => ['type' => self::UINT, 'default' => \XF::$time], + 'request_method' => ['type' => self::STR, 'maxLength' => 10, 'required' => true], + 'request_uri' => ['type' => self::STR, 'required' => true], + 'request_data' => ['type' => self::SERIALIZED_ARRAY, 'default' => []], + 'response_code' => ['type' => self::UINT, 'default' => 0], + 'response_output' => ['type' => self::SERIALIZED_ARRAY, 'default' => []] + ]; + + $structure->relations = [ + 'User' => [ + 'type' => self::TO_ONE, + 'entity' => 'XF:User', + 'conditions' => 'user_id', + 'primary' => true + ] + ]; + + return $structure; + } +} diff --git a/Entity/RefreshToken.php b/Entity/RefreshToken.php new file mode 100644 index 00000000..399e61ab --- /dev/null +++ b/Entity/RefreshToken.php @@ -0,0 +1,97 @@ +refresh_token_text; + } + + /** + * @param string $columnName + * @return \XF\Phrase|null + */ + public function getEntityColumnLabel($columnName) + { + switch ($columnName) { + case 'client_id': + case 'expire_date': + case 'refresh_token_text': + case 'scope': + return \XF::phrase('bdapi_' . $columnName); + case 'user_id': + return \XF::phrase('user_name'); + } + + return null; + } + + /** + * @return string + */ + public function getEntityLabel() + { + return $this->refresh_token_text; + } + + public static function getStructure(Structure $structure) + { + /** @var Server $apiServer */ + $apiServer = \XF::app()->container('api.server'); + + $structure->table = 'xf_bdapi_refresh_token'; + $structure->shortName = 'Xfrocks\Api:RefreshToken'; + $structure->primaryKey = 'refresh_token_id'; + $structure->columns = [ + 'refresh_token_id' => ['type' => self::UINT, 'autoIncrement' => true, 'nullable' => true], + 'refresh_token_text' => [ + 'type' => self::STR, + 'maxLength' => 255, + 'default' => $apiServer->generateSecureKey(), + 'writeOnce' => true, + ], + 'client_id' => ['type' => self::STR, 'maxLength' => 255, 'required' => true], + 'user_id' => ['type' => self::UINT, 'default' => \XF::visitor()->user_id], + 'expire_date' => ['type' => self::UINT, 'default' => \XF::$time + $apiServer->getOptionRefreshTokenTTL()], + ]; + $structure->relations = [ + 'User' => [ + 'entity' => 'XF:User', + 'type' => self::TO_ONE, + 'conditions' => 'user_id', + 'primary' => true + ], + 'Client' => [ + 'entity' => 'Xfrocks\Api:Client', + 'type' => self::TO_ONE, + 'conditions' => 'client_id', + 'primary' => true + ] + ]; + + self::addDefaultTokenElements($structure); + + return $structure; + } +} diff --git a/Entity/Subscription.php b/Entity/Subscription.php new file mode 100644 index 00000000..a86d5fbe --- /dev/null +++ b/Entity/Subscription.php @@ -0,0 +1,112 @@ +topic; + } + + public static function getStructure(Structure $structure) + { + $structure->table = 'xf_bdapi_subscription'; + $structure->primaryKey = 'subscription_id'; + $structure->shortName = 'Xfrocks\Api:Subscription'; + + $structure->columns = [ + 'subscription_id' => ['type' => self::UINT, 'nullable' => true, 'autoIncrement' => true], + 'client_id' => ['type' => self::STR, 'required' => true, 'maxLength' => 255], + 'callback' => ['type' => self::STR, 'required' => true], + 'topic' => ['type' => self::STR, 'required' => true, 'maxLength' => 255, 'writeOnce' => true], + 'subscribe_date' => ['type' => self::UINT, 'default' => \XF::$time], + 'expire_date' => ['type' => self::UINT, 'default' => 0] + ]; + + $structure->options = [ + self::OPTION_UPDATE_CALLBACKS => true + ]; + + $structure->relations = [ + 'Client' => [ + 'entity' => 'Xfrocks\Api:Client', + 'type' => self::TO_ONE, + 'conditions' => 'client_id', + 'primary' => true + ] + ]; + + return $structure; + } + + /** + * @return void + */ + protected function _postSave() + { + if ($this->getOption(self::OPTION_UPDATE_CALLBACKS) === true) { + $this->subscriptionRepo()->updateCallbacksForTopic($this->topic); + } + } + + /** + * @return void + */ + protected function _postDelete() + { + if ($this->getOption(self::OPTION_UPDATE_CALLBACKS) === true) { + $this->subscriptionRepo()->updateCallbacksForTopic($this->topic); + } + } + + /** + * @return \Xfrocks\Api\Repository\Subscription + */ + protected function subscriptionRepo() + { + /** @var \Xfrocks\Api\Repository\Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + + return $subRepo; + } +} diff --git a/Entity/Token.php b/Entity/Token.php new file mode 100644 index 00000000..d2542141 --- /dev/null +++ b/Entity/Token.php @@ -0,0 +1,139 @@ +token_text; + } + + /** + * @param string $columnName + * @return \XF\Phrase|null + */ + public function getEntityColumnLabel($columnName) + { + switch ($columnName) { + case 'client_id': + case 'expire_date': + case 'scope': + case 'token_text': + return \XF::phrase('bdapi_' . $columnName); + case 'user_id': + return \XF::phrase('user_name'); + } + + return null; + } + + /** + * @return string + */ + public function getEntityLabel() + { + return $this->token_text; + } + + /** + * @return void + * @throws \XF\Db\Exception + */ + protected function _postSave() + { + if ($this->isChanged('scope')) { + $this->updateUserScopes(); + } + } + + /** + * @return void + * @throws \XF\Db\Exception + */ + protected function updateUserScopes() + { + $db = $this->db(); + + $values = []; + foreach ($this->getScopes() as $scope) { + $values[] = sprintf( + '(%s, %d, %s, %d)', + $db->quote($this->get('client_id')), + $this->get('user_id'), + $db->quote($scope), + \XF::$time + ); + } + if (count($values) === 0) { + return; + } + + $db->query(' + INSERT IGNORE INTO `xf_bdapi_user_scope` + (`client_id`, `user_id`, `scope`, `accept_date`) + VALUES ' . implode(', ', $values) . ' + '); + } + + public static function getStructure(Structure $structure) + { + /** @var Server $apiServer */ + $apiServer = \XF::app()->container('api.server'); + + $structure->table = 'xf_bdapi_token'; + $structure->shortName = 'Xfrocks\Api:Token'; + $structure->primaryKey = 'token_id'; + $structure->columns = [ + 'token_id' => ['type' => self::UINT, 'autoIncrement' => true, 'nullable' => true], + 'token_text' => [ + 'type' => self::STR, + 'maxLength' => 255, + 'default' => $apiServer->generateSecureKey(), + 'writeOnce' => true, + ], + 'client_id' => ['type' => self::STR, 'maxLength' => 255, 'required' => true], + 'user_id' => ['type' => self::UINT, 'default' => \XF::visitor()->user_id], + 'expire_date' => ['type' => self::UINT, 'default' => \XF::$time + $apiServer->getOptionAccessTokenTTL()], + 'issue_date' => ['type' => self::UINT, 'default' => \XF::$time], + ]; + $structure->relations = [ + 'User' => [ + 'entity' => 'XF:User', + 'type' => self::TO_ONE, + 'conditions' => 'user_id', + 'primary' => true + ], + 'Client' => [ + 'entity' => 'Xfrocks\Api:Client', + 'type' => self::TO_ONE, + 'conditions' => 'client_id', + 'primary' => true + ] + ]; + + self::addDefaultTokenElements($structure); + + return $structure; + } +} diff --git a/Entity/TokenWithScope.php b/Entity/TokenWithScope.php new file mode 100644 index 00000000..dfe47be2 --- /dev/null +++ b/Entity/TokenWithScope.php @@ -0,0 +1,86 @@ +scopes; + if (in_array($newScope, $existingScopes, true)) { + return false; + } + + $scopes = $existingScopes + [$newScope]; + sort($scopes); + + $this->set('scope', implode(Listener::$scopeDelimiter, $scopes)); + return true; + } + + /** + * @return string[] + */ + public function getScopes() + { + $scopes = preg_split('#\s#', $this->scope, -1, PREG_SPLIT_NO_EMPTY); + if (!is_array($scopes)) { + return []; + } + + return array_map('trim', $scopes); + } + + /** + * @param string $scope + * @return bool + */ + public function hasScope($scope) + { + $scopes = $this->scopes; + return in_array($scope, $scopes, true); + } + + /** + * @param array $scopes + * @return void + */ + public function setScopes(array $scopes) + { + $this->set('scope', implode(Listener::$scopeDelimiter, $scopes)); + unset($this->_getterCache['scopes']); + } + + /** + * @param Structure $structure + * @return Structure + */ + protected static function addDefaultTokenElements(Structure $structure) + { + $structure->columns['scope'] = ['type' => self::STR, 'default' => Server::SCOPE_READ]; + $structure->getters['scopes'] = true; + + return $structure; + } +} diff --git a/Entity/UserScope.php b/Entity/UserScope.php new file mode 100644 index 00000000..71ac6ae2 --- /dev/null +++ b/Entity/UserScope.php @@ -0,0 +1,43 @@ +table = 'xf_bdapi_user_scope'; + $structure->shortName = 'Xfrocks\Api:UserScope'; + $structure->primaryKey = ['client_id', 'user_id', 'scope']; + $structure->columns = [ + 'client_id' => ['type' => self::STR, 'maxLength' => 255, 'readOnly' => true], + 'user_id' => ['type' => self::UINT, 'readOnly' => true], + 'scope' => ['type' => self::STR, 'maxLength' => 255, 'readOnly' => true], + 'accept_date' => ['type' => self::UINT, 'readOnly' => true] + ]; + + $structure->relations = [ + 'Client' => [ + 'type' => self::TO_ONE, + 'entity' => 'Xfrocks\Api:Client', + 'conditions' => 'client_id', + 'primary' => true, + ] + ]; + + return $structure; + } +} diff --git a/Finder/AuthCode.php b/Finder/AuthCode.php new file mode 100644 index 00000000..7de455af --- /dev/null +++ b/Finder/AuthCode.php @@ -0,0 +1,39 @@ +with(['Client', 'User']); + $this->setDefaultOrder('auth_code_id', 'desc'); + + return $filters; + } + + /** + * @param string $match + * @param bool $prefixMatch + * @return AuthCode + */ + public function entityDoXfFilter($match, $prefixMatch = false) + { + if (strlen($match) > 0) { + $this->where( + $this->columnUtf8('auth_code_text'), + 'LIKE', + $this->escapeLike($match, $prefixMatch ? '?%' : '%?%') + ); + } + + return $this; + } +} diff --git a/Finder/Client.php b/Finder/Client.php new file mode 100644 index 00000000..7a539eda --- /dev/null +++ b/Finder/Client.php @@ -0,0 +1,38 @@ +with('User'); + + return $filters; + } + + /** + * @param string $match + * @param bool $prefixMatch + * @return Client + */ + public function entityDoXfFilter($match, $prefixMatch = false) + { + if (strlen($match) > 0) { + $this->where( + $this->columnUtf8('name'), + 'LIKE', + $this->escapeLike($match, $prefixMatch ? '?%' : '%?%') + ); + } + + return $this; + } +} diff --git a/Finder/Log.php b/Finder/Log.php new file mode 100644 index 00000000..2bbd69ed --- /dev/null +++ b/Finder/Log.php @@ -0,0 +1,21 @@ +setDefaultOrder('request_date', 'DESC'); + + return $filters; + } +} diff --git a/Finder/RefreshToken.php b/Finder/RefreshToken.php new file mode 100644 index 00000000..962f398f --- /dev/null +++ b/Finder/RefreshToken.php @@ -0,0 +1,39 @@ +with(['Client', 'User']); + $this->setDefaultOrder('refresh_token_id', 'desc'); + + return $filters; + } + + /** + * @param string $match + * @param bool $prefixMatch + * @return RefreshToken + */ + public function entityDoXfFilter($match, $prefixMatch = false) + { + if (strlen($match) > 0) { + $this->where( + $this->columnUtf8('refresh_token_text'), + 'LIKE', + $this->escapeLike($match, $prefixMatch ? '?%' : '%?%') + ); + } + + return $this; + } +} diff --git a/Finder/Subscription.php b/Finder/Subscription.php new file mode 100644 index 00000000..cb59548c --- /dev/null +++ b/Finder/Subscription.php @@ -0,0 +1,53 @@ +whereOr( + ['expire_date', 0], + ['expire_date', '>', \XF::$time] + ); + + return $this; + } + + /** + * @param Entity $controller + * @param array $filters + * @return array + */ + public function entityDoListData($controller, array $filters) + { + $this->with(['Client']); + $this->setDefaultOrder('subscription_id', 'desc'); + + return $filters; + } + + /** + * @param string $match + * @param bool $prefixMatch + * @return Subscription + */ + public function entityDoXfFilter($match, $prefixMatch = false) + { + if (strlen($match) > 0) { + $this->where( + $this->columnUtf8('topic'), + 'LIKE', + $this->escapeLike($match, $prefixMatch ? '?%' : '%?%') + ); + } + + return $this; + } +} diff --git a/Finder/Token.php b/Finder/Token.php new file mode 100644 index 00000000..abb2c43f --- /dev/null +++ b/Finder/Token.php @@ -0,0 +1,39 @@ +with(['Client', 'User']); + $this->setDefaultOrder('token_id', 'desc'); + + return $filters; + } + + /** + * @param string $match + * @param bool $prefixMatch + * @return Token + */ + public function entityDoXfFilter($match, $prefixMatch = false) + { + if (strlen($match) > 0) { + $this->where( + $this->columnUtf8('token_text'), + 'LIKE', + $this->escapeLike($match, $prefixMatch ? '?%' : '%?%') + ); + } + + return $this; + } +} diff --git a/Job/ClientDelete.php b/Job/ClientDelete.php new file mode 100644 index 00000000..ea021af0 --- /dev/null +++ b/Job/ClientDelete.php @@ -0,0 +1,64 @@ +data['clientId'])) { + return $this->complete(); + } + + $timer = new Timer($maxRunTime); + $finder = $this->app + ->finder('Xfrocks\Api:Subscription') + ->where('client_id', $this->data['clientId']); + + foreach ($finder->limit(100)->fetch() as $subscription) { + if ($timer->limitExceeded()) { + break; + } + + $subscription->delete(); + } + + $remainTotal = $finder->total(); + if ($remainTotal > 0) { + return $this->resume(); + } + + return $this->complete(); + } + + /** + * @return string + */ + public function getStatusMessage() + { + return ''; + } +} diff --git a/Job/PingQueue.php b/Job/PingQueue.php new file mode 100644 index 00000000..fb6e9fde --- /dev/null +++ b/Job/PingQueue.php @@ -0,0 +1,51 @@ +run($maxRunTime)) { + $resume = $this->resume(); + $resume->continueDate = \XF::$time; + + return $resume; + } + + return $this->complete(); + } + + /** + * @return string + */ + public function getStatusMessage() + { + return ''; + } +} diff --git a/Listener.php b/Listener.php new file mode 100644 index 00000000..ef9992be --- /dev/null +++ b/Listener.php @@ -0,0 +1,127 @@ +extendClass('Xfrocks\Api\OAuth2\Server'); + return new $class($app); + }; + } + + /** + * @param \XF\App $app + * @return void + */ + public static function appSetup($app) + { + $container = $app->container(); + + $apiConfig = $app->config('api'); + if (is_array($apiConfig)) { + foreach ($apiConfig as $apiConfigKey => $apiConfigValue) { + switch ($apiConfigKey) { + case 'accessTokenParamKey': + case 'apiDirName': + case 'routerType': + case 'scopeDelimiter': + // @phpstan-ignore-next-line + self::$$apiConfigKey = $apiConfigValue; + break; + } + } + } + + if (!$container->offsetExists('api.server')) { + // this may have been done earlier by Xfrocks\Api\App + $container['api.server'] = self::apiServer($app); + } + + $container['api.transformer'] = function () use ($app) { + $class = $app->extendClass('Xfrocks\Api\Transformer'); + return new $class($app); + }; + + $container['router.' . self::$routerType] = function () use ($app) { + $class = $app->extendClass('Xfrocks\Api\Mvc\Router'); + return new $class($app); + }; + + $addOnCache = $container['addon.cache']; + $extension = $app->extension(); + if (isset($addOnCache['XFRM'])) { + $extension->addClassExtension('Xfrocks\Api\Controller\Search', 'Xfrocks\Api\XFRM\Controller\Search'); + $extension->addClassExtension('Xfrocks\Api\Data\Modules', 'Xfrocks\Api\XFRM\Data\Modules'); + } + } + + /** + * @param \XF\Mvc\Dispatcher $dispatcher + * @param \XF\Mvc\RouteMatch $match + * @return void + */ + public static function apiOnlyDispatcherMatch($dispatcher, &$match) + { + if ($match->getController() !== 'Xfrocks:Error') { + $request = $dispatcher->getRequest(); + + $action = $match->getAction(); + $method = strtolower($request->getServer('REQUEST_METHOD')); + if ($method === 'get' && \XF::$debugMode) { + $methodDebug = $request->filter('_xfApiMethod', 'str'); + if ($methodDebug !== '') { + $method = strtolower($methodDebug); + } + } + + switch ($method) { + case 'head': + $method = 'get'; + break; + case 'options': + $match->setParam('action', $match->getAction()); + $action = 'generic'; + break; + } + + $match->setAction(sprintf('%s/%s', $method, $action)); + } + } + + /** + * @param \XF\Service\User\ContentChange $changeService + * @param array $updates + * @return void + */ + public static function userContentChangeInit($changeService, array &$updates) + { + $updates['xf_bdapi_client'] = ['user_id']; + } +} diff --git a/Mvc/Reply/Api.php b/Mvc/Reply/Api.php new file mode 100644 index 00000000..1ef43b0c --- /dev/null +++ b/Mvc/Reply/Api.php @@ -0,0 +1,32 @@ +params; + } + + /** + * @param array $data + * @param bool $merge + * @return void + */ + public function setData(array $data, $merge = true) + { + $this->setParams($data, $merge); + } +} diff --git a/Mvc/Router.php b/Mvc/Router.php new file mode 100644 index 00000000..ed031e7a --- /dev/null +++ b/Mvc/Router.php @@ -0,0 +1,61 @@ +app = $app; + + /** @var Modules $modules */ + $modules = $app->data('Xfrocks\Api:Modules'); + + parent::__construct([$this, 'formatApiLink'], $modules->getRoutes()); + $this->setPather($app->container('request.pather')); + } + + /** + * @param mixed $link + * @param mixed|null $data + * @param array $parameters + * @param mixed|null $hash + * @return string + */ + public function buildLink($link, $data = null, array $parameters = [], $hash = null) + { + if (!isset($parameters[Listener::$accessTokenParamKey])) { + /** @var mixed $session */ + $session = $this->app->session(); + $getTokenText = [$session, 'getTokenText']; + if (is_callable($getTokenText)) { + $parameters[Listener::$accessTokenParamKey] = call_user_func($getTokenText); + } + } + + return parent::buildLink($link, $data, $parameters, $hash); + } + + /** + * @param string $route + * @param string $queryString + * @return string + */ + protected function formatApiLink($route, $queryString) + { + $suffix = $route . (strlen($queryString) > 0 ? '&' . $queryString : ''); + return sprintf('%s/index.php%s', Listener::$apiDirName, strlen($suffix) > 0 ? ('?' . $suffix) : ''); + } +} diff --git a/Mvc/Session/InMemoryStorage.php b/Mvc/Session/InMemoryStorage.php new file mode 100644 index 00000000..e0628f25 --- /dev/null +++ b/Mvc/Session/InMemoryStorage.php @@ -0,0 +1,46 @@ +xfToken = $xfToken; + $this->setId($xfToken->token_text); + $this->setExpireTime($xfToken->expire_date); + } + + /** + * @return Token + */ + public function getXfToken() + { + return $this->xfToken; + } +} diff --git a/OAuth2/Entity/AuthCodeHybrid.php b/OAuth2/Entity/AuthCodeHybrid.php new file mode 100644 index 00000000..9ed4b0a4 --- /dev/null +++ b/OAuth2/Entity/AuthCodeHybrid.php @@ -0,0 +1,37 @@ +xfAuthCode = $xfAuthCode; + $this->setId($xfAuthCode->auth_code_text); + $this->setRedirectUri($xfAuthCode->redirect_uri); + $this->setExpireTime($xfAuthCode->expire_date); + } + + /** + * @return AuthCode + */ + public function getXfAuthCode() + { + return $this->xfAuthCode; + } +} diff --git a/OAuth2/Entity/ClientHybrid.php b/OAuth2/Entity/ClientHybrid.php new file mode 100644 index 00000000..0bee2c5e --- /dev/null +++ b/OAuth2/Entity/ClientHybrid.php @@ -0,0 +1,38 @@ +xfClient = $xfClient; + $this->hydrate([ + 'id' => $xfClient->client_id, + 'name' => $xfClient->name + ]); + } + + /** + * @return Client + */ + public function getXfClient() + { + return $this->xfClient; + } +} diff --git a/OAuth2/Entity/RefreshTokenHybrid.php b/OAuth2/Entity/RefreshTokenHybrid.php new file mode 100644 index 00000000..cc34b597 --- /dev/null +++ b/OAuth2/Entity/RefreshTokenHybrid.php @@ -0,0 +1,54 @@ +xfRefreshToken = $xfRefreshToken; + $this->setId($xfRefreshToken->refresh_token_text); + $this->setExpireTime($xfRefreshToken->expire_date); + + /** @var AccessTokenStorage $accessTokenStorage */ + $accessTokenStorage = $server->getAccessTokenStorage(); + $fakeTokenText = $accessTokenStorage->generateFakeTokenText(); + + $xfApp = $xfRefreshToken->app(); + /** @var Token $fakeAccessToken */ + $fakeAccessToken = $xfApp->em()->instantiateEntity('Xfrocks\Api:Token', [ + 'token_id' => 0, + 'token_text' => $fakeTokenText, + 'client_id' => $xfRefreshToken->client_id, + 'user_id' => $xfRefreshToken->user_id, + 'scope' => $xfRefreshToken->scope + ]); + $accessToken = new AccessTokenHybrid($server, $fakeAccessToken); + $this->setAccessToken($accessToken); + } + + /** + * @return RefreshToken + */ + public function getXfRefreshToken() + { + return $this->xfRefreshToken; + } +} diff --git a/OAuth2/Grant/ImplicitGrant.php b/OAuth2/Grant/ImplicitGrant.php new file mode 100644 index 00000000..2c034397 --- /dev/null +++ b/OAuth2/Grant/ImplicitGrant.php @@ -0,0 +1,40 @@ + $accessToken->getId(), + 'token_type' => 'Bearer', + 'expires_in' => $accessToken->getExpireTime() - time(), + 'state' => $authParams['state'], + ]); + + return $redirectUri; + } + + public function completeFlow() + { + throw new \LogicException('This grant does not used this method'); + } +} diff --git a/OAuth2/Server.php b/OAuth2/Server.php new file mode 100644 index 00000000..d613ea0e --- /dev/null +++ b/OAuth2/Server.php @@ -0,0 +1,585 @@ +app = $app; + + $this->container = new Container(); + + $this->container['grant.auth_code'] = function () { + $authCode = new AuthCodeGrant(); + $authCode->setAuthTokenTTL($this->getOptionAuthCodeTTL()); + + return $authCode; + }; + + $this->container['grant.client_credentials'] = function () { + return new ClientCredentialsGrant(); + }; + + $this->container['grant.implicit'] = function () { + return new ImplicitGrant(); + }; + + $this->container['grant.password'] = function () { + return new PasswordGrant(); + }; + + $this->container['grant.refresh_token'] = function () { + $refreshToken = new RefreshTokenGrant(); + $refreshToken->setRefreshTokenTTL($this->getOptionRefreshTokenTTL()); + + return $refreshToken; + }; + + $this->container['request'] = function (Container $c) { + $request = Request::createFromGlobals(); + + // TODO: verify whether using token from query for all requests violates OAuth2 spec + $queryAccessToken = $request->query->get(Listener::$accessTokenParamKey, ''); + if (strlen($queryAccessToken) > 0) { + $bodyAccessToken = $request->request->get(Listener::$accessTokenParamKey, ''); + if ($bodyAccessToken === '') { + $request->request->set(Listener::$accessTokenParamKey, $queryAccessToken); + } + } + + return $request; + }; + + $this->container['server.auth'] = function (Container $c) { + $authorizationServer = new AuthorizationServer(); + $authorizationServer->setAccessTokenTTL($this->getOptionAccessTokenTTL()) + ->setDefaultScope(self::SCOPE_READ) + ->setScopeDelimiter(Listener::$scopeDelimiter) + ->addGrantType($c['grant.auth_code']) + ->addGrantType($c['grant.client_credentials']) + ->addGrantType($c['grant.password']) + ->addGrantType($c['grant.implicit']) + ->addGrantType($c['grant.refresh_token']) + ->setAccessTokenStorage($c['storage.access_token']) + ->setAuthCodeStorage($c['storage.auth_code']) + ->setClientStorage($c['storage.client']) + ->setRefreshTokenStorage($c['storage.refresh_token']) + ->setRequest($c['request']) + ->setScopeStorage($c['storage.scope']) + ->setSessionStorage($c['storage.session']); + + $authorizationServer->setTokenType(new BearerWithScope()); + + return $authorizationServer; + }; + + $this->container['server.resource'] = function (Container $c) { + $resourceServer = new ResourceServer( + $c['storage.session'], + $c['storage.access_token'], + $c['storage.client'], + $c['storage.scope'] + ); + $resourceServer->setIdKey(Listener::$accessTokenParamKey) + ->setRequest($c['request']); + + return $resourceServer; + }; + + $this->container['storage.access_token'] = function () { + return new AccessTokenStorage($this->app); + }; + + $this->container['storage.auth_code'] = function () { + return new AuthCodeStorage($this->app); + }; + + $this->container['storage.client'] = function () { + return new ClientStorage($this->app); + }; + + $this->container['storage.refresh_token'] = function () { + return new RefreshTokenStorage($this->app); + }; + + $this->container['storage.scope'] = function () { + return new ScopeStorage($this->app); + }; + + $this->container['storage.session'] = function () { + return new SessionStorage($this->app); + }; + } + + /** + * @param string|null $key + * @return Container|mixed + */ + public function container($key = null) + { + return $key === null ? $this->container : $this->container[$key]; + } + + /** + * @return string + */ + public function generateSecureKey() + { + return SecureKey::generate(); + } + + /** + * @return int + */ + public function getOptionAccessTokenTTL() + { + return $this->app->options()->bdApi_tokenTTL; + } + + /** + * @return int + */ + public function getOptionAuthCodeTTL() + { + return $this->app->options()->bdApi_authCodeTTL; + } + + /** + * @return int + */ + public function getOptionRefreshTokenTTL() + { + return $this->app->options()->bdApi_refreshTokenTTLDays * 86400; + } + + /** + * @return string[] + */ + public function getScopeDefaults() + { + $scopes = []; + $scopes[] = Server::SCOPE_READ; + $scopes[] = Server::SCOPE_POST; + $scopes[] = Server::SCOPE_MANAGE_ACCOUNT_SETTINGS; + $scopes[] = Server::SCOPE_PARTICIPATE_IN_CONVERSATIONS; + + return $scopes; + } + + /** + * @param string $scopeId + * @return null|\XF\Phrase + */ + public function getScopeDescription($scopeId) + { + switch ($scopeId) { + case self::SCOPE_READ: + case self::SCOPE_POST: + case self::SCOPE_MANAGE_ACCOUNT_SETTINGS: + case self::SCOPE_PARTICIPATE_IN_CONVERSATIONS: + case self::SCOPE_MANAGE_SYSTEM: + break; + default: + return null; + } + + return \XF::phrase('bdapi_scope_' . $scopeId); + } + + /** + * @param array $scopes + * @param AbstractServer $server + * @return array + */ + public function getScopeObjArrayFromStrArray(array $scopes, $server) + { + $result = []; + + foreach ($scopes as $scope) { + if (!is_string($scope)) { + continue; + } + + $description = $this->getScopeDescription($scope); + if ($description === null) { + continue; + } + + $result[$scope] = (new ScopeEntity($server))->hydrate([ + 'id' => $scope, + 'description' => $description + ]); + } + + return $result; + } + + /** + * @param array $scopes + * @return array + */ + public function getScopeStrArrayFromObjArray(array $scopes) + { + $scopeIds = []; + + /** @var ScopeEntity $scope */ + foreach ($scopes as $scope) { + $scopeIds[] = $scope->getId(); + } + + return $scopeIds; + } + + /** + * @param Account $controller + * @return array + * @throws \XF\Mvc\Reply\Exception + * @throws \League\OAuth2\Server\Exception\InvalidGrantException + */ + public function grantAuthCodeCheckParams($controller) + { + /** @var AuthorizationServer $authorizationServer */ + $authorizationServer = $this->container['server.auth']; + + /** @var AuthCodeGrant $authCodeGrant */ + $authCodeGrant = $authorizationServer->getGrantType('authorization_code'); + + try { + $params = $authCodeGrant->checkAuthorizeParams(); + + if (isset($params['client'])) { + /** @var ClientHybrid $client */ + $client = $params['client']; + $params['client'] = $client->getXfClient(); + } + + if (isset($params['scopes'])) { + $scopes = $params['scopes']; + $params['scopes'] = $this->getScopeStrArrayFromObjArray($scopes); + } + + return $params; + } catch (\League\OAuth2\Server\Exception\OAuthException $e) { + throw $this->buildControllerException($controller, $e); + } + } + + /** + * @param Account $controller + * @param array $params + * @return \XF\Mvc\Reply\Redirect + * @throws \League\OAuth2\Server\Exception\InvalidGrantException + * @throws \League\OAuth2\Server\Exception\UnsupportedResponseTypeException + * @throws \XF\PrintableException + */ + public function grantAuthCodeNewAuthRequest($controller, array $params) + { + /** @var AuthorizationServer $authorizationServer */ + $authorizationServer = $this->container['server.auth']; + + $userId = $this->app->session()->get(SessionStorage::SESSION_KEY_USER_ID); + $responseType = isset($params['response_type']) ? $params['response_type'] : 'code'; + switch ($responseType) { + case 'code': + /** @var AuthCodeGrant $authCodeGrant */ + $authCodeGrant = $authorizationServer->getGrantType('authorization_code'); + $authCodeType = SessionStorage::OWNER_TYPE_USER; + $authCodeTypeId = $userId; + $authCodeParams = $params; + + if (isset($authCodeParams['client'])) { + /** @var Client $xfClient */ + $xfClient = $authCodeParams['client']; + $authCodeParams['client'] = $authorizationServer->getClientStorage()->get($xfClient->client_id); + } + + if (isset($authCodeParams['scopes'])) { + $scopes = $authCodeParams['scopes']; + $authCodeParams['scopes'] = $this->getScopeObjArrayFromStrArray($scopes, $authorizationServer); + } + + $redirectUri = $authCodeGrant->newAuthorizeRequest($authCodeType, $authCodeTypeId, $authCodeParams); + break; + case 'token': + $accessToken = $this->newAccessToken($userId, $params['client'], $params['scopes']); + /** @var ImplicitGrant $implicitGrant */ + $implicitGrant = $authorizationServer->getGrantType('implicit'); + $redirectUri = $implicitGrant->authorize($accessToken, $params); + break; + default: + throw new \League\OAuth2\Server\Exception\UnsupportedResponseTypeException( + $responseType, + $params['redirect_uri'] + ); + } + + return $controller->redirect($redirectUri); + } + + /** + * @param OAuth2 $controller + * @return array + * @throws \XF\Mvc\Reply\Exception + * @throws \League\OAuth2\Server\Exception\InvalidGrantException + * @throws \XF\PrintableException + */ + public function grantFinalize($controller) + { + /** @var AuthorizationServer $authorizationServer */ + $authorizationServer = $this->container['server.auth']; + + $request = $authorizationServer->getRequest()->request; + $clientId = $request->get('client_id', ''); + $password = $request->get('password', ''); + $passwordAlgo = $request->get('password_algo', ''); + if (strlen($clientId) > 0 && strlen($password) > 0 && strlen($passwordAlgo) > 0) { + /** @var Client|null $client */ + $client = $this->app->find('Xfrocks\Api:Client', $clientId); + if ($client !== null) { + $decryptedPassword = Crypt::decrypt($password, $passwordAlgo, $client->client_secret); + if ($decryptedPassword !== false) { + $request->set('client_secret', $client->client_secret); + $request->set('password', $decryptedPassword); + $request->set('password_algo', ''); + } + } + } + + $grantType = $request->get('grant_type', ''); + if ($grantType === 'password') { + $scope = $request->get('scope', ''); + if ($scope === '') { + $scopeDefaults = implode(Listener::$scopeDelimiter, $this->getScopeDefaults()); + $request->set('scope', $scopeDefaults); + } + } + + /** @var PasswordGrant $passwordGrant */ + $passwordGrant = $authorizationServer->getGrantType('password'); + $passwordGrant->setVerifyCredentialsCallback(function ($username, $password) use ($controller) { + return $controller->verifyCredentials($username, $password); + }); + + $db = $controller->app()->db(); + $db->beginTransaction(); + try { + $data = $authorizationServer->issueAccessToken(); + + $db->commit(); + + return $data; + } catch (\League\OAuth2\Server\Exception\OAuthException $e) { + $db->rollback(); + + throw $this->buildControllerException($controller, $e); + } + } + + /** + * @param mixed $userId + * @param Client $client + * @param string[] $scopes + * @return AccessTokenEntity + * @throws \XF\PrintableException + */ + public function newAccessToken($userId, $client, array $scopes) + { + /** @var Token $xfToken */ + $xfToken = $this->app->em()->create('Xfrocks\Api:Token'); + $xfToken->client_id = $client->client_id; + $xfToken->expire_date = time() + $this->getOptionAccessTokenTTL(); + $xfToken->user_id = intval($userId); + $xfToken->setScopes($scopes); + $xfToken->save(); + + /** @var AuthorizationServer $authorizationServer */ + $authorizationServer = $this->container['server.auth']; + + return new AccessTokenHybrid($authorizationServer, $xfToken); + } + + /** + * @param mixed $userId + * @param Client $client + * @param string[] $scopes + * @return RefreshTokenHybrid + * @throws \XF\PrintableException + */ + public function newRefreshToken($userId, $client, array $scopes) + { + /** @var RefreshToken $xfRefreshToken */ + $xfRefreshToken = $this->app->em()->create('Xfrocks\Api:RefreshToken'); + $xfRefreshToken->client_id = $client->client_id; + $xfRefreshToken->expire_date = time() + $this->getOptionRefreshTokenTTL(); + $xfRefreshToken->user_id = intval($userId); + $xfRefreshToken->setScopes($scopes); + $xfRefreshToken->save(); + + /** @var AuthorizationServer $authorizationServer */ + $authorizationServer = $this->container['server.auth']; + + return new RefreshTokenHybrid($authorizationServer, $xfRefreshToken); + } + + /** + * @return AccessTokenHybrid|null + */ + public function parseRequest() + { + if ($this->parsedRequest) { + throw new \RuntimeException('Cannot parse request twice'); + } + + $this->parsedRequest = true; + + /** @var ResourceServer $resourceServer */ + $resourceServer = $this->container['server.resource']; + + try { + $xfToken = OneTimeToken::parse($this, $resourceServer->determineAccessToken(false)); + if ($xfToken !== null) { + return new AccessTokenHybrid($resourceServer, $xfToken); + } + } catch (\League\OAuth2\Server\Exception\InvalidRequestException $ire) { + // request does not have an access token, no need to go further + return null; + } + + $accessDenied = false; + try { + $resourceServer->isValidRequest(false); + } catch (\League\OAuth2\Server\Exception\AccessDeniedException $ade) { + $accessDenied = true; + } catch (\League\OAuth2\Server\Exception\OAuthException $e) { + // ignore other exception + } + + if ($accessDenied) { + return null; + } + + /** @var AccessTokenHybrid|null $accessTokenHybrid */ + $accessTokenHybrid = $resourceServer->getAccessToken(); + + return $accessTokenHybrid; + } + + /** + * @param string $key + * @param mixed $value + * @return void + */ + public function setRequestQuery($key, $value) + { + $this->app->request()->set($key, $value); + + /** @var Request $request */ + $request = $this->container['request']; + $request->query->set($key, $value); + } + + /** + * @param Controller $controller + * @param \League\OAuth2\Server\Exception\OAuthException $e + * @return \XF\Mvc\Reply\Exception + */ + protected function buildControllerException($controller, $e) + { + $errors = []; + if ($e->errorType !== '') { + switch ($e->errorType) { + case 'access_denied': + case 'invalid_client': + case 'invalid_grant': + case 'invalid_scope': + case 'unauthorized_client': + case 'unsupported_grant_type': + case 'unsupported_response_type': + $errors[] = \XF::phrase('bdapi_oauth2_error_' . $e->errorType); + break; + case 'invalid_credentials': + $errors[] = \XF::phrase('incorrect_password'); + break; + case 'server_error': + $errors[] = \XF::phrase('server_error_occurred'); + break; + } + } + if (count($errors) === 0) { + $errors[] = $e->getMessage(); + } + + if ($e->httpStatusCode >= 500) { + \XF::logException($e, false, 'API:', true); + } + + if ($e->shouldRedirect()) { + return $controller->exception($controller->redirect($e->getRedirectUri())); + } + + // TODO: include $e->getHttpHeaders() data + + return $controller->errorException($errors, $e->httpStatusCode); + } +} diff --git a/OAuth2/Storage/AbstractStorage.php b/OAuth2/Storage/AbstractStorage.php new file mode 100644 index 00000000..d7f4c296 --- /dev/null +++ b/OAuth2/Storage/AbstractStorage.php @@ -0,0 +1,138 @@ +app = $app; + } + + /** + * @param AbstractServer $server + * @return void + */ + final public function setServer(AbstractServer $server) + { + $this->server = $server; + } + + /** + * @param TokenWithScope $xfEntity + * @return bool + * @throws \Exception + * @throws \XF\PrintableException + */ + protected function doXfEntityDelete($xfEntity) + { + $deleted = $xfEntity->delete(true, false); + + if ($deleted) { + $text = $xfEntity->getText(); + if (isset($this->xfEntities[$text])) { + unset($this->xfEntities[$text]); + } + } + + return $deleted; + } + + /** + * @param string $shortName + * @param string $textColumn + * @param string $text + * @return TokenWithScope|null + */ + protected function doXfEntityFind($shortName, $textColumn, $text) + { + if (isset($this->xfEntities[$text])) { + return $this->xfEntities[$text]; + } + + $with = $this->getXfEntityWith(); + + /** @var TokenWithScope|null $xfEntity */ + $xfEntity = $this->app->em()->findOne($shortName, [$textColumn => $text], $with); + if ($xfEntity !== null) { + $this->xfEntities[$text] = $xfEntity; + } + + return $xfEntity; + } + + /** + * @param TokenWithScope $xfEntity + * @return bool + * @throws \Exception + * @throws \XF\PrintableException + */ + protected function doXfEntitySave($xfEntity) + { + $saved = $xfEntity->save(true, false); + + if ($saved) { + $text = $xfEntity->getText(); + $this->xfEntities[$text] = $xfEntity; + } + + return $saved; + } + + /** + * @param array $scopes + * @return string + */ + protected function scopeBuildStrFromObjArray(array $scopes) + { + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + $scopeIds = $apiServer->getScopeStrArrayFromObjArray($scopes); + + return implode(Listener::$scopeDelimiter, $scopeIds); + } + + /** + * @param array $scopes + * @return array + */ + protected function scopeBuildObjArrayFromStrArray(array $scopes) + { + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + return $apiServer->getScopeObjArrayFromStrArray($scopes, $this->server); + } + + /** + * @return array + */ + protected function getXfEntityWith() + { + return []; + } +} diff --git a/OAuth2/Storage/AccessTokenStorage.php b/OAuth2/Storage/AccessTokenStorage.php new file mode 100644 index 00000000..9b9ff8cd --- /dev/null +++ b/OAuth2/Storage/AccessTokenStorage.php @@ -0,0 +1,144 @@ +getHybrid($token); + if ($hybrid === null) { + throw new \RuntimeException('Access token cloud not be found ' . $token->getId()); + } + + $xfToken = $hybrid->getXfToken(); + if ($xfToken->associateScope($scope->getId())) { + $this->doXfEntitySave($xfToken); + } + } + + /** + * @param string $token + * @param int $expireTime + * @param int|string $sessionId + * @return void + * @throws \XF\PrintableException + */ + public function create($token, $expireTime, $sessionId) + { + /** @var SessionStorage $sessionStorage */ + $sessionStorage = $this->server->getSessionStorage(); + /** @var array $sessionCache */ + $sessionCache = $sessionStorage->getInMemoryCache($sessionId, true); + + /** @var Token $xfToken */ + $xfToken = $this->app->em()->create('Xfrocks\Api:Token'); + $xfToken->bulkSet([ + 'client_id' => $sessionCache[SessionStorage::SESSION_KEY_CLIENT_ID], + 'token_text' => $token, + 'expire_date' => $expireTime, + 'user_id' => $sessionCache[SessionStorage::SESSION_KEY_USER_ID] + ]); + $xfToken->setScopes($sessionCache[SessionStorage::SESSION_KEY_SCOPES]); + + $this->doXfEntitySave($xfToken); + } + + /** + * @param AccessTokenEntity $token + * @return void + * @throws \XF\PrintableException + */ + public function delete(AccessTokenEntity $token) + { + if (isset($this->fakeTokenTexts[$token->getId()])) { + return; + } + + $hybrid = $this->getHybrid($token); + if ($hybrid === null) { + return; + } + + $this->doXfEntityDelete($hybrid->getXfToken()); + } + + /** + * @return string + */ + public function generateFakeTokenText() + { + $tokenText = SecureKey::generate(); + + $this->fakeTokenTexts[$tokenText] = true; + + return $tokenText; + } + + /** + * @param string $token + * @return AccessTokenHybrid|null + */ + public function get($token) + { + /** @var Token|null $xfToken */ + $xfToken = $this->doXfEntityFind('Xfrocks\Api:Token', 'token_text', $token); + if ($xfToken === null) { + return null; + } + + return new AccessTokenHybrid($this->server, $xfToken); + } + + public function getScopes(AccessTokenEntity $token) + { + /** @var AccessTokenHybrid $accessTokenHybrid */ + $accessTokenHybrid = $token; + return $this->scopeBuildObjArrayFromStrArray($accessTokenHybrid->getXfToken()->scopes); + } + + /** + * @param AccessTokenEntity $token + * @return AccessTokenHybrid|null + */ + protected function getHybrid($token) + { + if ($token instanceof AccessTokenHybrid) { + return $token; + } + + return $this->get($token->getId()); + } + + protected function getXfEntityWith() + { + /** @var User $userRepo */ + $userRepo = $this->app->repository('XF:User'); + $userWith = $userRepo->getVisitorWith(); + + $with = array_map(function ($with) { + return 'User.' . $with; + }, $userWith); + + return $with; + } +} diff --git a/OAuth2/Storage/AuthCodeStorage.php b/OAuth2/Storage/AuthCodeStorage.php new file mode 100644 index 00000000..c3d0fa7e --- /dev/null +++ b/OAuth2/Storage/AuthCodeStorage.php @@ -0,0 +1,111 @@ +getHybrid($token); + if ($hybrid === null) { + return; + } + + $xfAuthCode = $hybrid->getXfAuthCode(); + if ($xfAuthCode->associateScope($scope->getId())) { + $this->doXfEntitySave($xfAuthCode); + } + } + + /** + * @param string $token + * @param int $expireTime + * @param int $sessionId + * @param string $redirectUri + * @return void + * @throws \XF\PrintableException + */ + public function create($token, $expireTime, $sessionId, $redirectUri) + { + /** @var SessionStorage $sessionStorage */ + $sessionStorage = $this->server->getSessionStorage(); + /** @var array $sessionCache */ + $sessionCache = $sessionStorage->getInMemoryCache($sessionId, true); + + /** @var AuthCode $xfAuthCode */ + $xfAuthCode = $this->app->em()->create('Xfrocks\Api:AuthCode'); + $xfAuthCode->bulkSet([ + 'client_id' => $sessionCache[SessionStorage::SESSION_KEY_CLIENT_ID], + 'auth_code_text' => $token, + 'redirect_uri' => $redirectUri, + 'expire_date' => $expireTime, + 'user_id' => $sessionCache[SessionStorage::SESSION_KEY_USER_ID] + ]); + $xfAuthCode->setScopes($sessionCache[SessionStorage::SESSION_KEY_SCOPES]); + + $this->doXfEntitySave($xfAuthCode); + } + + /** + * @param AuthCodeEntity $token + * @return void + * @throws \XF\PrintableException + */ + public function delete(AuthCodeEntity $token) + { + $hybrid = $this->getHybrid($token); + if ($hybrid !== null) { + $this->doXfEntityDelete($hybrid->getXfAuthCode()); + } + } + + /** + * @param string $code + * @return AuthCodeHybrid|null + */ + public function get($code) + { + /** @var AuthCode|null $xfAuthCode */ + $xfAuthCode = $this->doXfEntityFind('Xfrocks\Api:AuthCode', 'auth_code_text', $code); + if ($xfAuthCode === null) { + return null; + } + + return new AuthCodeHybrid($this->server, $xfAuthCode); + } + + public function getScopes(AuthCodeEntity $token) + { + $hybrid = $this->getHybrid($token); + if ($hybrid === null) { + return []; + } + + return $this->scopeBuildObjArrayFromStrArray($hybrid->getXfAuthCode()->scopes); + } + + /** + * @param AuthCodeEntity $token + * @return AuthCodeHybrid|null + */ + protected function getHybrid($token) + { + if ($token instanceof AuthCodeHybrid) { + return $token; + } + + return $this->get($token->getId()); + } +} diff --git a/OAuth2/Storage/ClientStorage.php b/OAuth2/Storage/ClientStorage.php new file mode 100644 index 00000000..85ce6c83 --- /dev/null +++ b/OAuth2/Storage/ClientStorage.php @@ -0,0 +1,41 @@ +doXfEntityFind('Xfrocks\Api:Client', 'client_id', $clientId); + if ($xfClient === null) { + return null; + } + + if ($clientSecret !== null && $xfClient->client_secret !== $clientSecret) { + return null; + } + + if ($redirectUri !== null && !$xfClient->isValidRedirectUri($redirectUri)) { + return null; + } + + return new ClientHybrid($this->server, $xfClient); + } + + public function getBySession(SessionEntity $session) + { + /** @var SessionStorage $sessionStorage */ + $sessionStorage = $this->server->getSessionStorage(); + $sessionCache = $sessionStorage->getInMemoryCache($session->getId()); + if (!isset($sessionCache[SessionStorage::SESSION_KEY_CLIENT_ID])) { + return null; + } + + return $this->get($sessionCache[SessionStorage::SESSION_KEY_CLIENT_ID]); + } +} diff --git a/OAuth2/Storage/RefreshTokenStorage.php b/OAuth2/Storage/RefreshTokenStorage.php new file mode 100644 index 00000000..4d3964c9 --- /dev/null +++ b/OAuth2/Storage/RefreshTokenStorage.php @@ -0,0 +1,75 @@ +doXfEntityFind('Xfrocks\Api:RefreshToken', 'refresh_token_text', $token); + if ($xfRefreshToken === null) { + return null; + } + + return new RefreshTokenHybrid($this->server, $xfRefreshToken); + } + + /** + * @param string $token + * @param int $expireTime + * @param string $accessToken + * @return RefreshTokenHybrid + * @throws \XF\PrintableException + */ + public function create($token, $expireTime, $accessToken) + { + /** @var AccessTokenHybrid $accessTokenHybrid */ + $accessTokenHybrid = $this->server->getAccessTokenStorage()->get($accessToken); + $xfToken = $accessTokenHybrid->getXfToken(); + + /** @var RefreshToken $xfRefreshToken */ + $xfRefreshToken = $this->app->em()->create('Xfrocks\Api:RefreshToken'); + $xfRefreshToken->bulkSet([ + 'client_id' => $xfToken->client_id, + 'refresh_token_text' => $token, + 'expire_date' => $expireTime, + 'user_id' => $xfToken->user_id, + 'scope' => $xfToken->scope + ]); + + $this->doXfEntitySave($xfRefreshToken); + + return new RefreshTokenHybrid($this->server, $xfRefreshToken); + } + + /** + * @param RefreshTokenEntity $token + * @return void + * @throws \XF\PrintableException + */ + public function delete(RefreshTokenEntity $token) + { + /** @var RefreshTokenHybrid $hybrid */ + $hybrid = $token; + + if (!$token instanceof RefreshTokenHybrid) { + $hybrid = $this->get($token->getId()); + if ($hybrid === null) { + return; + } + } + + $this->doXfEntityDelete($hybrid->getXfRefreshToken()); + } +} diff --git a/OAuth2/Storage/ScopeStorage.php b/OAuth2/Storage/ScopeStorage.php new file mode 100644 index 00000000..13f539eb --- /dev/null +++ b/OAuth2/Storage/ScopeStorage.php @@ -0,0 +1,28 @@ +app->container('api.server'); + $description = $apiServer->getScopeDescription($scope); + if ($description === null) { + return null; + } + + $result = new ScopeEntity($this->server); + $result->hydrate([ + 'id' => $scope, + 'description' => $description + ]); + + return $result; + } +} diff --git a/OAuth2/Storage/SessionStorage.php b/OAuth2/Storage/SessionStorage.php new file mode 100644 index 00000000..2e18b90a --- /dev/null +++ b/OAuth2/Storage/SessionStorage.php @@ -0,0 +1,143 @@ +sessions[$sessionId])) { + if ($throw) { + throw new \InvalidArgumentException('Session could not be found ' . $sessionId); + } + return null; + } + + return $this->sessions[$sessionId]; + } + + public function getByAccessToken(AccessTokenEntity $accessToken) + { + /** @var AccessTokenHybrid $accessTokenHybrid */ + $accessTokenHybrid = $accessToken; + $xfToken = $accessTokenHybrid->getXfToken(); + + $result = new SessionEntity($this->server); + + $sessionId = md5($xfToken->token_text); + $result->setId($sessionId); + + $userId = $xfToken->user_id; + $result->setOwner(self::OWNER_TYPE_USER, strval($userId)); + + $this->sessions[$sessionId] = [ + self::SESSION_KEY_CLIENT_ID => $xfToken->client_id, + self::SESSION_KEY_SCOPES => $xfToken->scopes, + self::SESSION_KEY_USER_ID => $userId + ]; + + return $result; + } + + public function getByAuthCode(AuthCodeEntity $authCode) + { + /** @var AuthCodeHybrid $authCodeHybrid */ + $authCodeHybrid = $authCode; + $xfAuthCode = $authCodeHybrid->getXfAuthCode(); + + $session = new SessionEntity($this->server); + + $sessionId = md5($xfAuthCode->auth_code_text); + $session->setId($sessionId); + + $userId = $xfAuthCode->user_id; + $session->setOwner(self::OWNER_TYPE_USER, strval($userId)); + + $this->sessions[$sessionId] = [ + self::SESSION_KEY_CLIENT_ID => $xfAuthCode->client_id, + self::SESSION_KEY_SCOPES => $xfAuthCode->scopes, + self::SESSION_KEY_USER_ID => $userId + ]; + + return $session; + } + + public function getScopes(SessionEntity $session) + { + $sessionId = strval($session->getId()); + if ($sessionId === '') { + return []; + } + + $cache = $this->getInMemoryCache($sessionId); + if (!is_array($cache) || !isset($cache[self::SESSION_KEY_SCOPES])) { + return []; + } + + return $this->scopeBuildObjArrayFromStrArray($cache[self::SESSION_KEY_SCOPES]); + } + + public function create($ownerType, $ownerId, $clientId, $clientRedirectUri = null) + { + $sessionId = count($this->sessions) + 1; + + $cache = [ + self::SESSION_KEY_CLIENT_ID => $clientId, + self::SESSION_KEY_SCOPES => [] + ]; + + if ($ownerType === self::OWNER_TYPE_USER) { + $cache[self::SESSION_KEY_USER_ID] = $ownerId; + } else { + $cache[self::SESSION_KEY_USER_ID] = 0; + } + + $this->sessions[$sessionId] = $cache; + + return $sessionId; + } + + public function associateScope(SessionEntity $session, ScopeEntity $scope) + { + $sessionId = $session->getId(); + $cache = $this->getInMemoryCache($sessionId, true); + + if (!isset($cache[self::SESSION_KEY_SCOPES])) { + $cache[self::SESSION_KEY_SCOPES] = []; + } + $scopesRef =& $cache[self::SESSION_KEY_SCOPES]; + + $scopeId = $scope->getId(); + if (in_array($scopeId, $scopesRef, true)) { + return; + } + + $scopesRef[] = $scopeId; + $this->sessions[$sessionId] = $cache; + } +} diff --git a/OAuth2/TokenType/BearerWithScope.php b/OAuth2/TokenType/BearerWithScope.php new file mode 100644 index 00000000..c8700e9c --- /dev/null +++ b/OAuth2/TokenType/BearerWithScope.php @@ -0,0 +1,30 @@ +session !== null) { + $scopes = $this->session->getScopes(); + $scopeIds = []; + foreach ($scopes as $scope) { + $scopeIds[] = $scope->getId(); + } + $response['scope'] = implode(Listener::$scopeDelimiter, $scopeIds); + + if ($this->session->getOwnerType() === SessionStorage::OWNER_TYPE_USER) { + $response['user_id'] = intval($this->session->getOwnerId()); + } + } + + return $response; + } +} diff --git a/Option/ColumnOption.php b/Option/ColumnOption.php new file mode 100644 index 00000000..b8ffce8a --- /dev/null +++ b/Option/ColumnOption.php @@ -0,0 +1,97 @@ +option_id); + switch ($subscriptionTopicType) { + case 'User': + $table = 'xf_user_option'; + break; + case 'UserNotification': + $table = 'xf_user_option'; + break; + case 'ThreadPost': + $table = 'xf_thread'; + break; + default: + throw new \XF\PrintableException(sprintf('Unsupported option %s', $subscriptionTopicType)); + } + + $column = \XF::app() + ->options() + ->offsetGet('bdApi_subscriptionColumn' . $subscriptionTopicType); + + if (!self::checkColumnExists($table, $column, $option)) { + return false; + } + + return true; + } + + /** + * @param string $value + * @param \XF\Entity\Option $option + * @return bool + * @throws \XF\PrintableException + */ + public static function verifyTextboxOption(&$value, $option) + { + $subscriptionTopicType = preg_replace('/^.+subscriptionColumn/', '', $option->option_id); + switch ($subscriptionTopicType) { + case 'User': + $table = 'xf_user_option'; + break; + case 'UserNotification': + $table = 'xf_user_option'; + break; + case 'ThreadPost': + $table = 'xf_thread'; + break; + default: + throw new \XF\PrintableException(sprintf('Unsupported option %s', $subscriptionTopicType)); + } + + if (strlen($value) > 0 && !self::checkColumnExists($table, $value, $option)) { + return false; + } + + return true; + } + + /** + * @param string $table + * @param string $column + * @param \XF\Entity\Option $option + * @return bool + */ + protected static function checkColumnExists($table, $column, $option) + { + $existed = \XF::db()->fetchOne(sprintf('SHOW COLUMNS FROM `%s` LIKE "%s"', $table, $column)); + if (!$existed) { + $option->error(\XF::phrase('bdapi_column_x_table_y_not_found_for_field_z', [ + 'column' => $column, + 'table' => $table, + 'field' => $option->option_id, + ])); + + return false; + } + + return true; + } +} diff --git a/Pub/View/Misc/ApiData.php b/Pub/View/Misc/ApiData.php new file mode 100644 index 00000000..ffbab24e --- /dev/null +++ b/Pub/View/Misc/ApiData.php @@ -0,0 +1,24 @@ +response); + + if (isset($this->params['callback']) && strlen($this->params['callback']) > 0) { + $this->response->contentType('application/x-javascript'); + return sprintf('%s(%s);', $this->params['callback'], json_encode($this->params['data'])); + } else { + $this->response->contentType('application/json'); + return json_encode($this->params['data']); + } + } +} diff --git a/Pub/View/Subscription/Post.php b/Pub/View/Subscription/Post.php new file mode 100644 index 00000000..b06aef4f --- /dev/null +++ b/Pub/View/Subscription/Post.php @@ -0,0 +1,24 @@ +params['httpResponseCode'])) { + $this->response->httpCode($this->params['httpResponseCode']); + } + + if (isset($this->params['message'])) { + return $this->params['message']; + } else { + return ''; + } + } +} diff --git a/Repository/AuthCode.php b/Repository/AuthCode.php new file mode 100644 index 00000000..ad5f7c86 --- /dev/null +++ b/Repository/AuthCode.php @@ -0,0 +1,30 @@ +db()->delete('xf_bdapi_auth_code', 'expire_date < ?', \XF::$time); + } + + /** + * @param string $clientId + * @param int $userId + * @return int + */ + public function deleteAuthCodes($clientId, $userId) + { + return $this->db()->delete( + 'xf_bdapi_auth_code', + 'client_id = ? AND user_id = ?', + [$clientId, $userId] + ); + } +} diff --git a/Repository/Client.php b/Repository/Client.php new file mode 100644 index 00000000..4d600604 --- /dev/null +++ b/Repository/Client.php @@ -0,0 +1,20 @@ +finder('Xfrocks\Api:Client'); + $finder->where('user_id', $userId); + + return $finder; + } +} diff --git a/Repository/Log.php b/Repository/Log.php new file mode 100644 index 00000000..c5dc19ba --- /dev/null +++ b/Repository/Log.php @@ -0,0 +1,144 @@ +options()->bdApi_logRetentionDays; + $cutoff = \XF::$time - $days * 86400; + + return $this->db()->delete('xf_bdapi_log', 'request_date < ?', $cutoff); + } + + /** + * @param string $requestMethod + * @param string $requestUri + * @param array $requestData + * @param int $responseCode + * @param array $responseOutput + * @param array $bulkSet + * @return bool + * @throws \XF\PrintableException + */ + public function logRequest( + $requestMethod, + $requestUri, + array $requestData, + $responseCode, + array $responseOutput, + array $bulkSet = [] + ) { + if (self::$logging < 1) { + return false; + } + + $days = $this->options()->bdApi_logRetentionDays; + if ($days == 0) { + return false; + } + + /** @var \Xfrocks\Api\Entity\Log $log */ + $log = $this->em->create('Xfrocks\Api:Log'); + + $log->bulkSet($bulkSet); + if (!isset($bulkSet['client_id'])) { + /** @var Session $session */ + $session = $this->app()->session(); + $token = $session->getToken(); + $log->client_id = ''; + + if ($token !== null) { + $log->client_id = $token->client_id; + } + } + + if (!isset($bulkSet['user_id'])) { + $log->user_id = \XF::visitor()->user_id; + } + + if (!isset($bulkSet['ip_address'])) { + $log->ip_address = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : ''; + } + + $log->request_method = $requestMethod; + $log->request_uri = $requestUri; + $log->request_data = $this->filterData($requestData); + $log->response_code = $responseCode; + $log->response_output = $this->filterData($responseOutput); + + return $log->save(false, false); + } + + /** + * @param array $data + * @param int $level + * @return array + */ + protected function filterData(array &$data, $level = 0) + { + $filtered = array(); + + foreach ($data as $key => &$value) { + $keyFirstChar = substr($key, 0, 1); + if ($keyFirstChar === '.' + || $keyFirstChar === '_' + ) { + continue; + } + + if (is_array($value)) { + if ($level < 3) { + $filtered[$key] = $this->filterData($value, $level + 1); + } else { + $filtered[$key] = '(array)'; + } + } else { + if (is_bool($value) || is_string($value) || is_numeric($value)) { + $filtered[$key] = $value; + } elseif (is_object($value)) { + if ($value instanceof \XF\Phrase) { + $filtered[$key] = $value->getName(); + } elseif ($value instanceof LazyTransformer) { + $filtered[$key] = $value->getLogData(); + } else { + $filtered[$key] = get_class($value); + } + } else { + $filtered[$key] = '?'; + } + } + } + + return $filtered; + } +} diff --git a/Repository/PingQueue.php b/Repository/PingQueue.php new file mode 100644 index 00000000..27023de2 --- /dev/null +++ b/Repository/PingQueue.php @@ -0,0 +1,242 @@ +db()->insert('xf_bdapi_ping_queue', array( + 'callback_md5' => md5($callback), + 'callback' => $callback, + 'object_type' => $objectType, + 'data' => serialize($data), + 'queue_date' => $queueDate, + 'expire_date' => $expireDate, + )); + + $triggerDate = null; + if ($queueDate > 0) { + $triggerDate = $queueDate; + } + + $this->app() + ->jobManager() + ->enqueueLater(__CLASS__, $triggerDate, 'Xfrocks\Api\Job\PingQueue', [], false); + } + + /** + * @param array $records + * @return void + */ + public function reInsertQueue(array $records) + { + foreach ($records as $record) { + $data = $record['data']; + + if (!isset($data['_retries'])) { + $data['_retries'] = 0; + } else { + $data['_retries']++; + } + if ($data['_retries'] > 5) { + // too many tries + continue; + } + + $queueDate = time() + intval(60 * pow(2, $data['_retries'] - 1)); + + $this->insertQueue($record['callback'], $record['object_type'], $data, $record['expire_date'], $queueDate); + } + } + + /** + * @return bool + */ + public function hasQueue() + { + $minId = $this->db()->fetchOne(' + SELECT MIN(ping_queue_id) + FROM xf_bdapi_ping_queue + WHERE queue_date < ? + ', \XF::$time); + + return (bool)$minId; + } + + /** + * @param int $limit + * @return array + */ + public function getQueue($limit = 20) + { + $queueRecords = $this->db()->fetchAllKeyed($this->db()->limit(' + SELECT * + FROM xf_bdapi_ping_queue + WHERE queue_date < ? + ORDER BY callback_md5 + ', $limit), 'ping_queue_id', [\XF::$time]); + + foreach ($queueRecords as &$record) { + $record['data'] = Php::safeUnserialize($record['data']); + } + + return $queueRecords; + } + + /** + * @param int $maxRunTime + * @return bool + */ + public function run($maxRunTime) + { + $s = microtime(true); + + do { + $queueRecords = $this->getQueue($maxRunTime > 0 ? 20 : 0); + + $this->ping($queueRecords); + + if ($maxRunTime > 0 && microtime(true) - $s > $maxRunTime) { + break; + } + } while ($queueRecords); + + return $this->hasQueue(); + } + + /** + * @param array $queueRecords + * @return void + */ + public function ping(array $queueRecords) + { + while (count($queueRecords) > 0) { + $records = []; + + foreach (array_keys($queueRecords) as $key) { + if (count($records) == 0 + || $queueRecords[$key]['callback'] === $records[0]['callback'] + ) { + $record = $queueRecords[$key]; + unset($queueRecords[$key]); + + if (!$this->db()->delete( + 'xf_bdapi_ping_queue', + 'ping_queue_id = ' . intval($record['ping_queue_id']) + ) + ) { + // already been deleted - run elsewhere + continue; + } + + if ($record['expire_date'] > 0 and $record['expire_date'] < \XF::$time) { + // expired + continue; + } + + $records[] = $record; + } + } + + $payloads = $this->preparePayloadsFromRecords($records); + if (count($payloads) === 0) { + continue; + } + + $client = $this->app()->http()->client(); + $reInserted = false; + + try { + $response = $client->post($records[0]['callback'], [ + 'json' => $payloads + ]); + + $responseBody = $response->getBody()->getContents(); + $responseCode = $response->getStatusCode(); + } catch (ClientException $e) { + $response = null; + $responseBody = $e->getMessage(); + $responseCode = 500; + } + + if ($responseCode < 200 or $responseCode > 299) { + $this->reInsertQueue($records); + $reInserted = true; + } + + if (\XF::$debugMode || $reInserted) { + /** @var Log $logRepo */ + $logRepo = $this->repository('Xfrocks\Api:Log'); + $logRepo->logRequest( + 'POST', + $records[0]['callback'], + $payloads, + $responseCode, + array('message' => $responseBody), + array( + 'client_id' => '', + 'user_id' => 0, + 'ip_address' => '127.0.0.1', + ) + ); + } + } + } + + /** + * @param array $records + * @return array + */ + protected function preparePayloadsFromRecords(array $records) + { + $dataByTypes = array(); + foreach ($records as $key => $record) { + /** @var string $objectTypeRef */ + $objectTypeRef =& $record['object_type']; + + if (!isset($dataByTypes[$objectTypeRef])) { + $dataByTypes[$objectTypeRef] = array(); + } + $dataRef =& $dataByTypes[$objectTypeRef]; + + $dataRef[$key] = $record['data']; + } + + /** @var Subscription $subscriptionRepo */ + $subscriptionRepo = $this->repository('Xfrocks\Api:Subscription'); + $payloadsByTypes = []; + foreach ($dataByTypes as $objectType => &$dataManyRef) { + $payloadsByTypes[$objectType] = $subscriptionRepo->preparePingDataMany($objectType, $dataManyRef); + } + + $payloads = array(); + foreach ($records as $key => $record) { + $objectTypeRef =& $record['object_type']; + + if (!isset($payloadsByTypes[$objectTypeRef])) { + continue; + } + $payloadsRef =& $payloadsByTypes[$objectTypeRef]; + + if (!isset($payloadsRef[$key])) { + continue; + } + $payloads[$key] = $payloadsRef[$key]; + } + + return $payloads; + } +} diff --git a/Repository/RefreshToken.php b/Repository/RefreshToken.php new file mode 100644 index 00000000..f1563a74 --- /dev/null +++ b/Repository/RefreshToken.php @@ -0,0 +1,30 @@ +db()->delete('xf_bdapi_refresh_token', 'expire_date < ?', \XF::$time); + } + + /** + * @param string $clientId + * @param int $userId + * @return int + */ + public function deleteRefreshTokens($clientId, $userId) + { + return $this->db()->delete( + 'xf_bdapi_refresh_token', + 'client_id = ? AND user_id = ?', + [$clientId, $userId] + ); + } +} diff --git a/Repository/Search.php b/Repository/Search.php new file mode 100644 index 00000000..df86aac0 --- /dev/null +++ b/Repository/Search.php @@ -0,0 +1,86 @@ +app()->inputFilterer(), $input->getFilteredValues()); + + $searcher = $this->app()->search(); + $query = $searcher->getQuery(); + + if ($contentType !== '') { + $typeHandler = $searcher->handler($contentType); + $urlConstraints = []; + + $query->forTypeHandler($typeHandler, $httpRequest, $urlConstraints); + } + + if (isset($input['q']) && strlen($input['q']) > 0) { + $query->withKeywords($input['q']); + } + + if (isset($input['user_id']) && $input['user_id'] > 0) { + $query->byUserId($input['user_id']); + } + + if (isset($options[self::OPTION_SEARCH_TYPE])) { + $query->inType($options[self::OPTION_SEARCH_TYPE]); + } + + if (isset($input['forum_id']) && $input['forum_id'] > 0) { + /** @var Node $nodeRepo */ + $nodeRepo = $this->repository('XF:Node'); + /** @var Forum|null $forum */ + $forum = $this->em->find('XF:Forum', $input['forum_id']); + $nodeIds = []; + + if ($forum != null) { + $node = $forum->Node; + if ($node !== null) { + $nodeIds = $nodeRepo->findChildren($node, false)->fetch()->keys(); + $nodeIds[] = $forum->node_id; + } + } + + $query->withMetadata('node', count($nodeIds) > 0 ? $nodeIds : $input['forum_id']); + } + + if (isset($input['thread_id']) && $input['thread_id'] > 0) { + $query->withMetadata('thread', $input['thread_id']) + ->inTitleOnly(false); + } + + if ($query->getErrors()) { + $errors = $query->getErrors(); + + throw new PrintableException(reset($errors)); + } + + /** @var \XF\Repository\Search $xfSearchRepo */ + $xfSearchRepo = $this->repository('XF:Search'); + /** @var \XF\Entity\Search|null $search */ + $search = $xfSearchRepo->runSearch($query, $constraints); + + return $search; + } +} diff --git a/Repository/Subscription.php b/Repository/Subscription.php new file mode 100644 index 00000000..30b37291 --- /dev/null +++ b/Repository/Subscription.php @@ -0,0 +1,813 @@ +app()->registry()->get(self::TYPE_CLIENT_DATA_REGISTRY); + + if (!is_array($data)) { + $data = array(); + } + + return $data; + } + + /** + * @param string $type + * @param int|string $id + * @return int|null + */ + public function deleteSubscriptionsForTopic($type, $id) + { + $topic = self::getTopic($type, $id); + $deleted = $this->db()->delete('xf_bdapi_subscription', 'topic = ?', $topic); + + return $deleted; + } + + /** + * @param string $clientId + * @param string $type + * @param int|string $id + * @return int + */ + public function deleteSubscriptions($clientId, $type, $id) + { + $topic = self::getTopic($type, $id); + $deleted = $this->db()->delete( + 'xf_bdapi_subscription', + 'client_id = ? AND topic = ?', + [$clientId, $topic] + ); + + if ($deleted > 0) { + $this->updateCallbacksForTopic($topic); + } + + return $deleted; + } + + /** + * @param string $topic + * @return void + */ + public function updateCallbacksForTopic($topic) + { + list($type, $id) = self::parseTopic($topic); + + /** @var \Xfrocks\Api\Finder\Subscription $finder */ + $finder = $this->finder('Xfrocks\Api:Subscription'); + $finder->active(); + $finder->where('topic', $topic); + + $apiRouter = $this->app()->router(Listener::$routerType); + + $subscriptions = []; + /** @var \Xfrocks\Api\Entity\Subscription $subscription */ + foreach ($finder->fetch() as $subscription) { + $subscriptions[$subscription->subscription_id] = $subscription->toArray(); + } + + switch ($type) { + case self::TYPE_NOTIFICATION: + if (count($subscriptions) > 0) { + $userOption = [ + 'topic' => $topic, + 'link' => $apiRouter->buildLink('notifications', null, ['oauth_token' => '']), + 'subscriptions' => $subscriptions, + ]; + } else { + $userOption = []; + } + + $this->db()->update( + 'xf_user_option', + [self::getSubColumn($type) => serialize($userOption)], + 'user_id = ?', + $id + ); + break; + case self::TYPE_THREAD_POST: + if (count($subscriptions) > 0) { + $threadOption = [ + 'topic' => $topic, + 'link' => $apiRouter->buildLink('posts', null, ['thread_id' => $id, 'oauth_token' => '']), + 'subscriptions' => $subscriptions, + ]; + } else { + $threadOption = []; + } + + $this->db()->update( + 'xf_thread', + [self::getSubColumn($type) => serialize($threadOption)], + 'thread_id = ?', + $id + ); + break; + case self::TYPE_USER: + if (count($subscriptions) > 0) { + $userOption = [ + 'topic' => $topic, + 'link' => $apiRouter->buildLink('users', ['user_id' => $id], ['oauth_token' => '']), + 'subscriptions' => $subscriptions, + ]; + } else { + $userOption = []; + } + + if ($id > 0) { + $this->db()->update( + 'xf_user_option', + [$this->options()->bdApi_subscriptionColumnUser => serialize($userOption)], + 'user_id = ?', + $id + ); + } else { + $this->app() + ->simpleCache() + ->setValue('Xfrocks/Api', self::TYPE_USER_0_SIMPLE_CACHE, $userOption); + } + break; + case self::TYPE_CLIENT: + if (count($subscriptions) > 0) { + $data = [ + 'topic' => $topic, + 'link' => '', + 'subscriptions' => $subscriptions, + ]; + } else { + $data = []; + } + + $this->app()->registry()->set(self::TYPE_CLIENT_DATA_REGISTRY, $data); + break; + } + } + + /** + * @param string $callback + * @param string $mode + * @param string $topic + * @param int $leaseSeconds + * @param array $extraParams + * @return bool + */ + public function verifyIntentOfSubscriber($callback, $mode, $topic, $leaseSeconds, array $extraParams = []) + { + $challenge = md5(\XF::$time . $callback . $mode . $topic . $leaseSeconds); + $challenge = md5($challenge . $this->app()->config('globalSalt')); + + $client = $this->app()->http()->client(); + + $requestData = array_merge(array( + 'hub.mode' => $mode, + 'hub.topic' => $topic, + 'hub.lease_seconds' => $leaseSeconds, + 'hub.challenge' => $challenge, + ), $extraParams); + + try { + $uri = $callback; + foreach ($requestData as $key => $value) { + $uri .= sprintf('%s%s=%s', strpos($uri, '?') === false ? '?' : '&', $key, rawurlencode($value)); + } + $response = $client->get($uri); + + $body = trim($response->getBody()->getContents()); + $httpCode = $response->getStatusCode(); + } catch (ClientException $e) { + $body = $e->getMessage(); + $httpCode = 500; + } + + if (\XF::$debugMode) { + /** @var Log $logRepo */ + $logRepo = $this->repository('Xfrocks\Api:Log'); + $logRepo->logRequest( + 'GET', + $callback, + $requestData, + $httpCode, + array('message' => $body), + array( + 'client_id' => '', + 'user_id' => 0, + 'ip_address' => '127.0.0.1', + ) + ); + } + + if ($body !== $challenge) { + return false; + } + + if ($httpCode < 200 or $httpCode > 299) { + return false; + } + + return true; + } + + /** + * @param string $topic + * @param User|null $user + * @return bool + */ + public function isValidTopic(&$topic, User $user = null) + { + list($type, $id) = self::parseTopic($topic); + + if ($type != self::TYPE_CLIENT && !self::getSubOption($type)) { + // subscription for this topic type has been disabled + return false; + } + + $user = $user !== null ? $user : \XF::visitor(); + /** @var Session $session */ + $session = \XF::app()->session(); + $token = $session->getToken(); + $client = $token !== null ? $token->Client : null; + + switch ($type) { + case self::TYPE_NOTIFICATION: + if ($id === 'me') { + // now supports user_notification_me + $id = $user->user_id; + $topic = self::getTopic($type, $id); + } + + return (($id > 0) and ($id == $user->user_id)); + case self::TYPE_THREAD_POST: + /** @var Thread|null $thread */ + $thread = $this->em->find('XF:Thread', $id); + if ($thread === null) { + return false; + } + + return $thread->user_id == $user->user_id; + case self::TYPE_USER: + if ($id === 'me') { + // now supports user_me + $id = $user->user_id; + $topic = self::getTopic($type, $id); + } + + if ($id === '0' && $client !== null) { + if (!isset($client->options['allow_user_0_subscription']) || + $client->options['allow_user_0_subscription'] < 1 + ) { + return false; + } + } + + return (intval($id) === intval($user->user_id)); + case self::TYPE_CLIENT: + return $client !== null; + } + + return false; + } + + /** + * @param array $params + * @param string $topicType + * @param int|string $topicId + * @param string $selfLink + * @param array|string $subscriptionOption + * @return bool + */ + public function prepareDiscoveryParams(array &$params, $topicType, $topicId, $selfLink, $subscriptionOption) + { + if (!self::getSubOption($topicType)) { + return false; + } + + $response = $this->app()->response(); + + $hubLink = $this->app()->router(Listener::$routerType)->buildLink('subscriptions', null, [ + 'hub.topic' => self::getTopic($topicType, $topicId), + 'oauth_token' => '' + ]); + + $response->header('Link', sprintf('<%s>; rel=hub', $hubLink), false); + if ($selfLink !== '') { + $response->header('Link', sprintf('<%s>; rel=self', $selfLink), false); + } + + /** @var Session $session */ + $session = $this->app()->session(); + $token = $session->getToken(); + $clientId = $token !== null ? $token->client_id : ''; + if (is_string($subscriptionOption)) { + $subscriptionOption = Php::safeUnserialize($subscriptionOption); + } + if (is_array($subscriptionOption) + && $clientId !== '' + && isset($subscriptionOption['subscriptions']) + ) { + foreach ($subscriptionOption['subscriptions'] as $subscription) { + if ($subscription['client_id'] == $clientId) { + $params['subscription_callback'] = $subscription['callback']; + } + } + } + + return true; + } + + /** + * @param array $option + * @param string $action + * @param string $objectType + * @param mixed $objectData + * @return bool + */ + public function ping(array $option, $action, $objectType, $objectData) + { + if (!isset($option['topic']) || !isset($option['subscriptions'])) { + return false; + } + + $pingedClientIds = array(); + + foreach ($option['subscriptions'] as $subscription) { + if ($subscription['expire_date'] > 0 + && $subscription['expire_date'] < \XF::$time + ) { + // expired + continue; + } + + if (in_array($subscription['client_id'], $pingedClientIds, true)) { + // duplicated subscription + continue; + } + $pingedClientIds[] = $subscription['client_id']; + + $pingData = array( + 'client_id' => $subscription['client_id'], + 'topic' => $option['topic'], + 'action' => $action, + 'object_data' => $objectData, + ); + + if (isset($option['link'])) { + $pingData['link'] = $option['link']; + } + + /** @var PingQueue $pingQueueRepo */ + $pingQueueRepo = $this->repository('Xfrocks\Api:PingQueue'); + $pingQueueRepo->insertQueue( + $subscription['callback'], + $objectType, + $pingData, + $subscription['expire_date'] + ); + } + + return true; + } + + /** + * @param string $action + * @param ConversationMessage $message + * @param User $alertedUser + * @param User|null $triggerUser + * @return bool + */ + public function pingConversationMessage( + $action, + ConversationMessage $message, + User $alertedUser, + User $triggerUser = null + ) { + $type = self::TYPE_NOTIFICATION; + if ($this->options()->bdApi_userNotificationConversation < 1 || !self::getSubOption($type)) { + return false; + } + + $userOption = $alertedUser->Option; + if ($userOption === null) { + return false; + } + + if ($action === 'reply') { + // XF has removed reply alert. So use own action to prevent conflict + $action = 'bdapi_reply'; + } + + $templater = $this->app()->templater(); + $conversation = $message->Conversation; + $triggerUser = $triggerUser !== null ? $triggerUser : $message->User; + if ($triggerUser === null) { + return false; + } + + $extraData = [ + 'object_data' => [ + 'notification_id' => 0, + 'notification_html' => '' + ] + ]; + + $extraData['object_data']['message'] = [ + 'conversation_id' => $message->conversation_id, + 'title' => $conversation !== null ? $conversation->title : '', + 'message_id' => $message->message_id, + 'message' => $templater->func('snippet', [$message->message, 140, ['stripQuote' => true]]) + ]; + + $fakeAlert = [ + 'alert_id' => 0, + 'alerted_user_id' => $alertedUser->user_id, + 'user_id' => $triggerUser->user_id, + 'username' => $triggerUser->username, + 'content_type' => 'conversation_message', + 'content_id' => $message->message_id, + 'action' => $action, + 'event_date' => \XF::$time, + 'view_date' => 0, + 'extra_data' => serialize($extraData) + ]; + + /** @var mixed $userOptionValue */ + $userOptionValue = $userOption->getValue(self::getSubColumn($type)); + if (!is_array($userOptionValue) || count($userOptionValue) === 0) { + return false; + } + + return $this->ping($userOptionValue, $action, $type, $fakeAlert); + } + + /** + * @param string $action + * @param Post $post + * @return bool + */ + public function pingThreadPost($action, Post $post) + { + $type = self::TYPE_THREAD_POST; + if (!self::getSubOption($type)) { + return false; + } + + $thread = $post->Thread; + if ($thread === null) { + return false; + } + + /** @var array|null $threadOptionValue */ + $threadOptionValue = $thread->getValue(self::getSubColumn($type)); + if ($threadOptionValue === null || count($threadOptionValue) === 0) { + return false; + } + + return $this->ping($threadOptionValue, $action, $type, $post->post_id); + } + + /** + * @param string $action + * @param User $user + * @return bool + */ + public function pingUser($action, User $user) + { + $type = self::TYPE_USER; + if (!self::getSubOption($type)) { + return false; + } + + if ($action === 'insert') { + $user0Option = $this->app()->simpleCache()->getValue('Xfrocks/Api', self::TYPE_USER_0_SIMPLE_CACHE); + if (is_array($user0Option) && count($user0Option) > 0) { + $this->ping($user0Option, $action, $type, $user->user_id); + } + } + + $userOption = $user->Option; + if ($userOption === null) { + return false; + } + + /** @var array $userOptionValue */ + $userOptionValue = $userOption->getValue(self::getSubColumn($type)); + if (count($userOptionValue) === 0) { + return false; + } + + return $this->ping($userOptionValue, $action, $type, $user->user_id); + } + + /** + * @param string $objectType + * @param array $pingDataMany + * @return array + */ + public function preparePingDataMany($objectType, array $pingDataMany) + { + if (!self::getSubOption($objectType)) { + return array(); + } + + switch ($objectType) { + case self::TYPE_NOTIFICATION: + return $this->preparePingDataManyNotification($pingDataMany); + case self::TYPE_THREAD_POST: + return $this->preparePingDataManyPost($pingDataMany); + case self::TYPE_USER: + return $this->preparePingDataManyUser($pingDataMany); + } + + return array(); + } + + /** + * @param array $pingDataMany + * @return array + */ + protected function preparePingDataManyNotification(array $pingDataMany) + { + $alertIds = array(); + $alerts = array(); + foreach ($pingDataMany as $key => &$pingDataRef) { + if (is_numeric($pingDataRef['object_data'])) { + $alertIds[] = $pingDataRef['object_data']; + } elseif (is_array($pingDataRef['object_data']) + && isset($pingDataRef['object_data']['alert_id']) + && $pingDataRef['object_data']['alert_id'] == 0 + ) { + $fakeAlertId = sprintf(md5($key)); + $pingDataRef['object_data']['alert_id'] = $fakeAlertId; + $alertRaw = $pingDataRef['object_data']; + + /** @var UserAlert $fakeAlert */ + $fakeAlert = $this->em->create('XF:UserAlert'); + $fakeAlert->alerted_user_id = $alertRaw['alerted_user_id']; + $fakeAlert->user_id = $alertRaw['user_id']; + $fakeAlert->username = $alertRaw['username']; + $fakeAlert->content_type = $alertRaw['content_type']; + $fakeAlert->content_id = $alertRaw['content_id']; + $fakeAlert->action = $alertRaw['action']; + $fakeAlert->event_date = $alertRaw['event_date']; + $fakeAlert->view_date = $alertRaw['view_date']; + + if (isset($alertRaw['extra_data'])) { + $fakeAlert->extra_data = is_array($alertRaw['extra_data']) + ? $alertRaw['extra_data'] + : Php::safeUnserialize($alertRaw['extra_data']); + } + + $fakeAlert->setReadOnly(true); + + $alerts[$fakeAlertId] = $fakeAlert; + $pingDataRef['object_data'] = $fakeAlertId; + } + } + + if (count($alertIds) > 0) { + $realAlerts = $this->em->findByIds('XF:UserAlert', $alertIds); + foreach ($realAlerts as $alertId => $alert) { + $alerts[$alertId] = $alert; + } + } + + $userIds = array(); + $alertsByUser = array(); + foreach ($alerts as $alert) { + $userIds[] = $alert['alerted_user_id']; + + if (!isset($alertsByUser[$alert['alerted_user_id']])) { + $alertsByUser[$alert['alerted_user_id']] = array(); + } + $alertsByUser[$alert['alerted_user_id']][$alert['alert_id']] = $alert; + } + + $viewingUsers = $this->preparePingDataGetViewingUsers($userIds); + + foreach ($alertsByUser as $userId => &$userAlerts) { + if (!isset($viewingUsers[$userId])) { + // user not found + foreach (array_keys($userAlerts) as $userAlertId) { + // delete the alert too + unset($alerts[$userAlertId]); + } + continue; + } + + foreach (array_keys($userAlerts) as $userAlertId) { + $alerts[$userAlertId] = $userAlerts[$userAlertId]; + } + } + + foreach (array_keys($pingDataMany) as $pingDataKey) { + $pingDataRef = &$pingDataMany[$pingDataKey]; + + if (!isset($pingDataRef['object_data'])) { + // no alert is attached to object data + continue; + } + $alertId = $pingDataRef['object_data']; + + if (!isset($alerts[$alertId])) { + // alert not found + unset($pingDataMany[$pingDataKey]); + continue; + } + $alertRef =& $alerts[$alertId]; + + if ($alertRef instanceof UserAlert) { + $transformContext = new TransformContext(); + /** @var Transformer $transformer */ + $transformer = $this->app()->container('api.transformer'); + + /** @var User $visitor */ + $visitor = \XF::visitor(); + if ($visitor->user_id < 1 && isset($alertRef['alerted_user_id']) && $alertRef['alerted_user_id'] > 0) { + /** @var User|null $visitor */ + $visitor = $this->em->find('XF:User', $alertRef['alerted_user_id']); + } + + try { + $pingDataRef['object_data'] = $visitor !== null + ? \XF::asVisitor( + $visitor, + function () use ($transformer, $transformContext, $alertRef) { + return $transformer->transformEntity($transformContext, null, $alertRef); + } + ) : []; + } catch (\Exception $e) { + $pingDataRef['object_data'] = []; + } + } + + if (!is_numeric($alertRef['alert_id']) && isset($alertRef['extra_data']['object_data'])) { + // fake alert, use the included object_data + $pingDataRef['object_data'] = array_merge( + $pingDataRef['object_data'], + $alertRef['extra_data']['object_data'] + ); + } + + $alertedUserId = $alertRef['alerted_user_id']; + if (isset($viewingUsers[$alertedUserId])) { + $alertedUser = $viewingUsers[$alertedUserId]; + if (isset($alertedUser['alerts_unread'])) { + $pingDataRef['object_data']['user_unread_notification_count'] = $alertedUser['alerts_unread']; + } + } + } + + return $pingDataMany; + } + + /** + * @param array $userIds + * @return array + */ + protected function preparePingDataGetViewingUsers(array $userIds) + { + static $allUsers = array(); + $users = array(); + + $dbUserIds = array(); + foreach ($userIds as $userId) { + if ($userId == \XF::visitor()->user_id) { + $users[$userId] = \XF::visitor(); + } elseif ($userId == 0) { + /** @var \XF\Repository\User $userRepo */ + $userRepo = $this->repository('XF:User'); + $users[$userId] = $userRepo->getGuestUser(); + } elseif (isset($allUsers[$userId])) { + $users[$userId] = $allUsers[$userId]; + } else { + $dbUserIds[] = $userId; + } + } + + if (count($dbUserIds) > 0) { + $dbUsers = $this->em->findByIds('XF:User', $dbUserIds, [ + 'Option', + 'Profile', + 'PermissionCombination', + 'Privacy' + ]); + + foreach ($dbUsers as $user) { + $allUsers[$user['user_id']] = $user; + $users[$user['user_id']] = $user; + } + } + + return $users; + } + + /** + * @param array $pingDataMany + * @return array + */ + protected function preparePingDataManyPost(array $pingDataMany) + { + // TODO: do anything here? + return $pingDataMany; + } + + /** + * @param array $pingDataMany + * @return array + */ + protected function preparePingDataManyUser(array $pingDataMany) + { + // TODO: do anything here? + return $pingDataMany; + } + + /** + * @param string $type + * @param int|string $id + * @return string + */ + public static function getTopic($type, $id) + { + return sprintf('%s_%s', $type, $id); + } + + /** + * @param string $topic + * @return array + */ + public static function parseTopic($topic) + { + if ($topic === '') { + return array(self::TYPE_CLIENT, 0); + } + + $parts = explode('_', $topic); + $id = array_pop($parts); + $type = implode('_', $parts); + + return [$type, $id]; + } + + /** + * @param string $topicType + * @return bool + */ + public static function getSubOption($topicType) + { + $options = \XF::options(); + $topicTypeCamelCase = str_replace(' ', '', ucwords(str_replace('_', ' ', $topicType))); + $optionKey = 'bdApi_subscription' . $topicTypeCamelCase; + + if (!$options->offsetExists($optionKey)) { + return false; + } + + return intval($options->offsetGet($optionKey)) > 0; + } + + /** + * @param string $topicType + * @return string + */ + public static function getSubColumn($topicType) + { + $options = \XF::options(); + $topicTypeCamelCase = str_replace(' ', '', ucwords(str_replace('_', ' ', $topicType))); + $optionKey = 'bdApi_subscriptionColumn' . $topicTypeCamelCase; + + if (!$options->offsetExists($optionKey)) { + return ''; + } + + return strval($options->offsetGet($optionKey)); + } +} diff --git a/Repository/Token.php b/Repository/Token.php new file mode 100644 index 00000000..83d071f8 --- /dev/null +++ b/Repository/Token.php @@ -0,0 +1,30 @@ +db()->delete('xf_bdapi_token', 'expire_date < ?', \XF::$time); + } + + /** + * @param string $clientId + * @param int $userId + * @return int + */ + public function deleteTokens($clientId, $userId) + { + return $this->db()->delete( + 'xf_bdapi_token', + 'client_id = ? AND user_id = ?', + [$clientId, $userId] + ); + } +} diff --git a/Repository/UserScope.php b/Repository/UserScope.php new file mode 100644 index 00000000..f59e3a2f --- /dev/null +++ b/Repository/UserScope.php @@ -0,0 +1,23 @@ +db()->delete( + 'xf_bdapi_user_scope', + 'client_id = ? AND user_id = ? AND scope = ?', + [$clientId, $userId, $scope] + ); + } +} diff --git a/Setup.php b/Setup.php new file mode 100644 index 00000000..11f5270d --- /dev/null +++ b/Setup.php @@ -0,0 +1,274 @@ +schemaManager(); + + foreach ($this->getTables() as $tableName => $closure) { + $sm->createTable($tableName, $closure); + } + } + + /** + * @return void + */ + public function uninstallStep1() + { + $sm = $this->schemaManager(); + + foreach (array_keys($this->getTables()) as $tableName) { + $sm->dropTable($tableName); + } + } + + /** + * @return void + */ + public function upgrade2000012Step1() + { + $sm = $this->schemaManager(); + + $sm->alterTable('xf_bdapi_auth_code', function (Alter $table) { + $table->changeColumn('client_id', 'varbinary')->length(255); + $table->changeColumn('auth_code_text', 'varbinary')->length(255); + }); + + $sm->alterTable('xf_bdapi_client', function (Alter $table) { + $table->changeColumn('client_id', 'varbinary')->length(255); + $table->changeColumn('client_secret', 'varbinary')->length(255); + $table->changeColumn('name', 'text'); + + $table->convertCharset('utf8mb4'); + }); + + $sm->alterTable('xf_bdapi_refresh_token', function (Alter $table) { + $table->changeColumn('client_id', 'varbinary')->length(255); + $table->changeColumn('refresh_token_text', 'varbinary')->length(255); + $table->changeColumn('scope', 'blob'); + }); + + $sm->alterTable('xf_bdapi_token', function (Alter $table) { + $table->changeColumn('client_id', 'varbinary')->length(255); + $table->changeColumn('token_text', 'varbinary')->length(255); + $table->changeColumn('scope', 'blob'); + }); + } + + /** + * @return void + */ + public function upgrade2000013Step1() + { + $sm = $this->schemaManager(); + + $sm->alterTable('xf_bdapi_auth_code', function (Alter $table) { + $table->addIndex()->type('key')->columns('expire_date'); + }); + + $sm->alterTable('xf_bdapi_client', function (Alter $table) { + $table->addIndex()->type('key')->columns('user_id'); + }); + + $sm->alterTable('xf_bdapi_refresh_token', function (Alter $table) { + $table->addIndex()->type('key')->columns('expire_date'); + }); + + $sm->alterTable('xf_bdapi_token', function (Alter $table) { + $table->addIndex()->type('key')->columns('client_id'); + $table->addIndex()->type('key')->columns('expire_date'); + $table->addIndex()->type('key')->columns('user_id'); + }); + } + + /** + * @return void + */ + public function upgrade2000014Step1() + { + $sm = $this->schemaManager(); + + foreach ($this->getTables2() as $tableName => $closure) { + $sm->createTable($tableName, $closure); + } + + $sm->alterTable('xf_bdapi_user_scope', function (Alter $table) { + $table->changeColumn('client_id', 'varbinary')->length(255); + $table->changeColumn('scope', 'varbinary')->length(255); + + $table->addIndex()->type('unique')->columns(['client_id', 'user_id', 'scope']); + }); + } + + /** + * @return void + */ + public function upgrade2000015Step1() + { + $sm = $this->schemaManager(); + + foreach ($this->getTables3() as $tableName => $closure) { + $sm->createTable($tableName, $closure); + } + } + + public function upgrade2000135Step1() + { + $this->schemaManager()->alterTable('xf_bdapi_token', function (Alter $table) { + $table->addColumn('issue_date', 'int')->setDefault(0); + }); + } + + /** + * @return array + */ + private function getTables() + { + $tables = []; + + $tables += $this->getTables1(); + $tables += $this->getTables2(); + $tables += $this->getTables3(); + + return $tables; + } + + /** + * @return array + */ + private function getTables1() + { + $tables = []; + + $tables['xf_bdapi_auth_code'] = function (Create $table) { + $table->addColumn('auth_code_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('auth_code_text', 'varbinary')->length(255); + $table->addColumn('redirect_uri', 'text'); + $table->addColumn('expire_date', 'int'); + $table->addColumn('user_id', 'int'); + $table->addColumn('scope', 'text'); + + $table->addUniqueKey('auth_code_text'); + }; + + $tables['xf_bdapi_client'] = function (Create $table) { + $table->addColumn('client_id', 'varbinary')->length(255)->primaryKey(); + $table->addColumn('client_secret', 'varbinary')->length(255); + $table->addColumn('redirect_uri', 'text'); + $table->addColumn('name', 'text'); + $table->addColumn('description', 'text'); + $table->addColumn('user_id', 'int'); + $table->addColumn('options', 'mediumblob')->nullable(true); + }; + + $tables['xf_bdapi_refresh_token'] = function (Create $table) { + $table->addColumn('refresh_token_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('refresh_token_text', 'varbinary')->length(255); + $table->addColumn('expire_date', 'int'); + $table->addColumn('user_id', 'int'); + $table->addColumn('scope', 'blob'); + + $table->addUniqueKey('refresh_token_text'); + }; + + $tables['xf_bdapi_token'] = function (Create $table) { + $table->addColumn('token_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('token_text', 'varbinary')->length(255); + $table->addColumn('expire_date', 'int'); + $table->addColumn('user_id', 'int'); + $table->addColumn('scope', 'blob'); + $table->addColumn('issue_date', 'int')->setDefault(0); + + $table->addUniqueKey('token_text'); + }; + + return $tables; + } + + /** + * @return array + */ + private function getTables2() + { + $tables = []; + + $tables['xf_bdapi_user_scope'] = function (Create $table) { + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('user_id', 'int'); + $table->addColumn('scope', 'varbinary')->length(255); + $table->addColumn('accept_date', 'int'); + + $table->addKey('user_id'); + $table->addUniqueKey(['client_id', 'user_id', 'scope']); + }; + + return $tables; + } + + /** + * @return array + */ + private function getTables3() + { + $tables = []; + + $tables['xf_bdapi_subscription'] = function (Create $table) { + $table->addColumn('subscription_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('callback', 'text'); + $table->addColumn('topic', 'varbinary')->length(255); + $table->addColumn('subscribe_date', 'int')->unsigned(); + $table->addColumn('expire_date', 'int')->unsigned()->setDefault(0); + + $table->addKey('client_id'); + $table->addKey('topic'); + }; + + $tables['xf_bdapi_log'] = function (Create $table) { + $table->addColumn('log_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('client_id', 'varbinary')->length(255); + $table->addColumn('user_id', 'int')->unsigned(); + $table->addColumn('ip_address', 'varbinary')->length(50); + $table->addColumn('request_date', 'int')->unsigned(); + $table->addColumn('request_method', 'varbinary')->length(10); + $table->addColumn('request_uri', 'text'); + $table->addColumn('request_data', 'mediumblob'); + $table->addColumn('response_code', 'int')->unsigned(); + $table->addColumn('response_output', 'mediumblob'); + }; + + $tables['xf_bdapi_ping_queue'] = function (Create $table) { + $table->addColumn('ping_queue_id', 'int')->autoIncrement()->primaryKey(); + $table->addColumn('callback_md5', 'varbinary')->length(32); + $table->addColumn('callback', 'text'); + $table->addColumn('object_type', 'varbinary', 25); + $table->addColumn('data', 'mediumblob'); + $table->addColumn('queue_date', 'int')->unsigned(); + $table->addColumn('expire_date', 'int')->unsigned()->setDefault(0); + + $table->addKey('callback_md5'); + }; + + return $tables; + } +} diff --git a/Transform/AbstractHandler.php b/Transform/AbstractHandler.php new file mode 100644 index 00000000..46508745 --- /dev/null +++ b/Transform/AbstractHandler.php @@ -0,0 +1,427 @@ +app = $app; + $this->transformer = $transformer; + $this->type = $type; + } + + /** + * @return void + */ + public function addAttachmentsToQueuedEntities() + { + /** @var \XF\Repository\Attachment $attachmentRepo */ + $attachmentRepo = $this->app->repository('XF:Attachment'); + + foreach (self::$entitiesToAddAttachmentsTo as $q) { + $attachmentRepo->addAttachmentsToContent( + $q['entities'], + $q['contentType'], + $q['countKey'], + $q['relationKey'] + ); + } + + self::$entitiesToAddAttachmentsTo = []; + } + + /** + * @param TransformContext $context + * @param string $key + * @return mixed + */ + public function calculateDynamicValue(TransformContext $context, $key) + { + return null; + } + + /** + * @param TransformContext $context + * @return bool + */ + public function canView(TransformContext $context) + { + /** @var callable $callable */ + $callable = [$context->getSource(), 'canView']; + + // we do not check is_callable here, PHP will just complain (loudly) if it doesn't work + return call_user_func($callable); + } + + /** + * @param TransformContext $context + * @return array|null + */ + public function collectLinks(TransformContext $context) + { + return null; + } + + /** + * @param TransformContext $context + * @return array|null + */ + public function collectPermissions(TransformContext $context) + { + return null; + } + + /** + * @param TransformContext $context + * @return array + */ + public function getMappings(TransformContext $context) + { + return []; + } + + /** + * @param TransformContext $context + * @return array + */ + public function onNewContext(TransformContext $context) + { + $context->makeSureSelectorIsNotNull($this->type); + + return []; + } + + /** + * @param TransformContext $context + * @param Finder $finder + * @return Finder + */ + public function onTransformFinder(TransformContext $context, Finder $finder) + { + foreach ($context->getOnTransformFinderCallbacks() as $callback) { + call_user_func_array($callback, [$context, $finder]); + } + + return $finder; + } + + /** + * @param TransformContext $context + * @param Entity[] $entities + * @return Entity[] + */ + public function onTransformEntities(TransformContext $context, $entities) + { + foreach ($context->getOnTransformEntitiesCallbacks() as $callback) { + call_user_func_array($callback, [$context, $entities]); + } + + return $entities; + } + + /** + * @param TransformContext $context + * @param array $data + * @return void + */ + public function onTransformed(TransformContext $context, array &$data) + { + foreach ($context->getOnTransformedCallbacks() as $callback) { + $params = [$context, &$data]; + call_user_func_array($callback, $params); + } + } + + /** + * @param string $link + * @param mixed $data + * @param array $parameters + * @return string + */ + protected function buildApiLink($link, $data = null, array $parameters = []) + { + $apiRouter = $this->app->router(Listener::$routerType); + return $apiRouter->buildLink($link, $data, $parameters); + } + + /** + * @param string $link + * @param mixed $data + * @param array $parameters + * @return string + */ + protected function buildPublicLink($link, $data = null, array $parameters = []) + { + $publicRouter = $this->app->router('public'); + return $publicRouter->buildLink($link, $data, $parameters); + } + + /** + * @param TransformContext $context + * @param Entity[]|AbstractCollection $entities + * @param string|null $contextKey + * @param string $relationKey + * @return void + */ + protected function callOnTransformEntitiesForRelation( + TransformContext $context, + $entities, + $contextKey, + $relationKey + ) { + if ($entities instanceof AbstractCollection) { + if ($entities->count() === 0) { + return; + } + + $firstEntity = $entities->first(); + } else { + $firstEntity = reset($entities); + } + + if ($firstEntity === false) { + return; + } + + $entityStructure = $firstEntity->structure(); + if (!isset($entityStructure->relations[$relationKey])) { + return; + } + + $relationConfig = $entityStructure->relations[$relationKey]; + if (!is_array($relationConfig) || + !isset($relationConfig['type']) || + !isset($relationConfig['entity']) + ) { + return; + } + + $subHandler = $this->transformer->handler($relationConfig['entity']); + $subContext = $context->getSubContext($contextKey, $subHandler); + + $subEntities = []; + foreach ($entities as $entity) { + if ($relationConfig['type'] === Entity::TO_ONE) { + /** @var Entity $subEntity */ + $subEntity = $entity->getRelation($relationKey); + $subEntities[$subEntity->getEntityId()] = $subEntity; + } else { + /** @var Entity[] $_subEntities */ + $_subEntities = $entity->getRelation($relationKey); + foreach ($_subEntities as $subEntity) { + $subEntities[$subEntity->getEntityId()] = $subEntity; + } + } + } + + $subHandler->onTransformEntities($subContext, $subEntities); + } + + /** + * @param TransformContext $context + * @param Finder $finder + * @param string|null $contextKey + * @param string $relationKey + * @param string|null $shortName + * @return void + */ + protected function callOnTransformFinderForRelation( + TransformContext $context, + Finder $finder, + $contextKey, + $relationKey, + $shortName = null + ) { + $finder->with($relationKey); + + if ($shortName === null) { + $shortName = $this->type; + } + + $em = $this->app->em(); + $entityStructure = $em->getEntityStructure($shortName); + if (!isset($entityStructure->relations[$relationKey])) { + return; + } + + $relationConfig = $entityStructure->relations[$relationKey]; + if (!is_array($relationConfig) || !isset($relationConfig['entity'])) { + return; + } + + $subHandler = $this->transformer->handler($relationConfig['entity']); + $subContext = $context->getSubContext($contextKey, $subHandler); + + $relationStructure = $em->getEntityStructure($relationConfig['entity']); + $finderClass = \XF::stringToClass($shortName, '%s\Finder\%s'); + try { + $finderClass = $this->app->extendClass($finderClass, '\XF\Mvc\Entity\Finder'); + } catch (\Exception $e) { + // ignore + } + if (!$finderClass || !class_exists($finderClass)) { + $finderClass = '\XF\Mvc\Entity\Finder'; + } + /** @var Finder $relationFinder */ + $relationFinder = new $finderClass($em, $relationStructure); + $relationFinder->setParentFinder($finder, $relationKey); + + $subHandler->onTransformFinder($subContext, $relationFinder); + } + + /** + * @param string $contentType + * @param Entity $entity + * @return bool + */ + protected function checkAttachmentCanManage($contentType, $entity) + { + /** @var \XF\Repository\Attachment $attachmentRepo */ + $attachmentRepo = $this->app->repository('XF:Attachment'); + $attachmentHandler = $attachmentRepo->getAttachmentHandler($contentType); + if ($attachmentHandler === null) { + return false; + } + + $attachmentContext = $attachmentHandler->getContext($entity); + return $attachmentHandler->canManageAttachments($attachmentContext); + } + + /** + * @param string $scope + * @return bool + */ + protected function checkSessionScope($scope) + { + /** @var mixed $session */ + $session = $this->app->session(); + $callable = [$session, 'hasScope']; + return is_callable($callable) && boolval(call_user_func($callable, $scope)); + } + + /** + * @param Entity[] $entities + * @param string $contentType + * @param string $countKey + * @param string $relationKey + * @return void + */ + protected function enqueueEntitiesToAddAttachmentsTo( + $entities, + $contentType, + $countKey = 'attach_count', + $relationKey = 'Attachments' + ) { + $key = sprintf('%s_%s_%s', $contentType, $countKey, $relationKey); + if (!isset(self::$entitiesToAddAttachmentsTo[$key])) { + self::$entitiesToAddAttachmentsTo[$key] = [ + 'entities' => [], + 'contentType' => $contentType, + 'countKey' => $countKey, + 'relationKey' => $relationKey, + ]; + } + $ref =& self::$entitiesToAddAttachmentsTo[$key]; + + foreach ($entities as $entityId => $entity) { + $ref['entities'][$entityId] = $entity; + } + } + + /** + * @return \XF\Template\Templater + */ + protected function getTemplater() + { + return $this->app->templater(); + } + + /** + * @param string $key + * @param string $string + * @param mixed $content + * @param array $options + * @return string + */ + protected function renderBbCodeHtml($key, $string, $content, array $options = []) + { + $string = utf8_trim($string); + if (strlen($string) === 0) { + return ''; + } + + $options['lightbox'] = false; + + $context = 'api:' . $key; + return $this->app->bbCode()->render($string, 'html', $context, $content, $options); + } + + /** + * @param string $string + * @param array $options + * @return string + */ + protected function renderBbCodePlainText($string, array $options = []) + { + $string = utf8_trim($string); + if (strlen($string) === 0) { + return ''; + } + + $formatter = $this->app->stringFormatter(); + return $formatter->stripBbCode($string, $options); + } +} diff --git a/Transform/AttachmentParent.php b/Transform/AttachmentParent.php new file mode 100644 index 00000000..4ca434bb --- /dev/null +++ b/Transform/AttachmentParent.php @@ -0,0 +1,34 @@ +data('reply'); + if ($reply === null) { + return null; + } + + if ($reply instanceof \XF\Mvc\Reply\Error) { + switch ($key) { + case self::DYNAMIC_KEY_ERROR: + return implode(', ', $reply->getErrors()); + case self::DYNAMIC_KEY_RESULT: + return self::RESULT_ERROR; + } + } elseif ($reply instanceof \XF\Mvc\Reply\Message) { + switch ($key) { + case self::DYNAMIC_KEY_MESSAGE: + return $reply->getMessage(); + case self::DYNAMIC_KEY_RESULT: + return self::RESULT_MESSAGE; + } + } elseif ($reply instanceof \XF\Mvc\Reply\Redirect) { + switch ($key) { + case self::DYNAMIC_KEY_RESULT: + return self::RESULT_REDIRECT; + case self::DYNAMIC_KEY_URL: + return $reply->getUrl(); + } + } elseif ($reply instanceof \Xfrocks\Api\Mvc\Reply\Api) { + switch ($key) { + case self::DYNAMIC_KEY_RESULT: + return self::RESULT_OK; + } + } else { + switch ($key) { + case self::DYNAMIC_KEY_RESPONSE: + $response = \XF::app()->dispatcher()->render($reply, $reply->getResponseType()); + $response->compressIfAble(false); + $response->includeContentLength(false); + + ob_start(); + try { + $response->sendBody(); + } catch (\Exception $e) { + // ignore any errors + } + $responseBody = ob_get_clean(); + if ($responseBody === false) { + return null; + } + + $mediaType = $response->contentType(); + if ($mediaType === 'text/html') { + $data = $responseBody; + } else { + $base64Data = base64_encode($responseBody); + $data = 'base64,' . $base64Data; + } + + return sprintf('data:%s;%s', $mediaType, $data); + case self::DYNAMIC_KEY_RESULT: + $responseCode = $reply->getResponseCode(); + if ($responseCode >= 200 && $responseCode < 300) { + return self::RESULT_OK; + } + + return self::RESULT_ERROR; + } + } + + return null; + } + + public function canView(TransformContext $context) + { + return true; + } + + public function getMappings(TransformContext $context) + { + return [ + self::DYNAMIC_KEY_ERROR, + self::DYNAMIC_KEY_MESSAGE, + self::DYNAMIC_KEY_RESPONSE, + self::DYNAMIC_KEY_RESULT, + self::DYNAMIC_KEY_URL, + ]; + } + + public function onNewContext(TransformContext $context) + { + $data = parent::onNewContext($context); + $data['reply'] = null; + + $reply = $context->getSource(); + while ($data['reply'] === null) { + if ($reply instanceof \XF\Mvc\Reply\Exception) { + $data['reply'] = $reply->getReply(); + } elseif ($reply instanceof \Xfrocks\Api\Mvc\Reply\Api) { + try { + $data['transformed'] = $this->prepareJsonEncode( + $this->transformer->transformArray($context, null, $reply->getData()) + ); + $data['reply'] = $reply; + } catch (\Exception $e) { + if ($e instanceof \XF\Mvc\Reply\Exception) { + $reply = $e; + } elseif ($e instanceof \XF\PrintableException || \XF::$debugMode) { + $reply = new \XF\Mvc\Reply\Message($e->getMessage()); + } else { + $reply = new \XF\Mvc\Reply\Error(\XF::phrase('unexpected_error_occurred')); + } + } + } elseif ($reply instanceof \XF\Mvc\Reply\AbstractReply) { + $data['reply'] = $reply; + } + } + + return $data; + } + + public function onTransformed(TransformContext $context, array &$data) + { + $transformed = $context->data('transformed'); + if (is_array($transformed)) { + $data += $transformed; + } + + parent::onTransformed($context, $data); + } + + /** + * @param mixed $value + * @return array + * @see \XF\Mvc\Renderer\Json::prepareJsonEncode() + */ + protected function prepareJsonEncode($value) + { + if (is_array($value)) { + foreach ($value as &$innerValue) { + $innerValue = $this->prepareJsonEncode($innerValue); + } + } else { + if (is_object($value) && method_exists($value, 'jsonSerialize')) { + $value = $value->jsonSerialize(); + } else { + if (is_object($value) && method_exists($value, '__toString')) { + $value = $value->__toString(); + } + } + } + + return $value; + } +} diff --git a/Transform/CustomField.php b/Transform/CustomField.php new file mode 100644 index 00000000..7551a389 --- /dev/null +++ b/Transform/CustomField.php @@ -0,0 +1,139 @@ +data('definition'); + if ($definition === null) { + return null; + } + + /** @var array|string|null $valueData */ + $valueData = $context->data('value'); + + switch ($key) { + case self::DYNAMIC_KEY_CHOICES: + if ($valueData !== null) { + return null; + } + + if (!$this->hasChoices($definition)) { + return null; + } + + $choices = []; + foreach ($definition['field_choices'] as $choiceKey => $choiceValue) { + $choices[] = ['key' => $choiceKey, 'value' => $choiceValue]; + } + + return $choices; + case self::DYNAMIC_KEY_DESCRIPTION: + return $definition['description']; + case self::DYNAMIC_KEY_ID: + return $definition['field_id']; + case self::DYNAMIC_KEY_IS_MULTI_CHOICE: + return $definition['type_group'] === 'multiple'; + case self::DYNAMIC_KEY_IS_REQUIRED: + if ($valueData !== null) { + return null; + } + + return $definition->isRequired(); + case self::DYNAMIC_KEY_POSITION: + return $definition['display_group']; + case self::DYNAMIC_KEY_TITLE: + return $definition['title']; + case self::DYNAMIC_KEY_VALUE: + if ($valueData === null || $this->hasChoices($definition)) { + return null; + } + + return utf8_trim(strval($valueData)); + case self::DYNAMIC_KEY_VALUES: + if ($valueData === null || !$this->hasChoices($definition)) { + return null; + } + + $choices = $definition['field_choices']; + $choiceKeys = is_array($valueData) ? $valueData : [strval($valueData)]; + $values = []; + foreach ($choiceKeys as $choiceKey) { + if (!isset($choices[$choiceKey])) { + continue; + } + + $values[] = [ + 'key' => $choiceKey, + 'value' => $choices[$choiceKey], + ]; + } + + return $values; + } + + return null; + } + + public function canView(TransformContext $context) + { + return true; + } + + public function getMappings(TransformContext $context) + { + return [ + self::DYNAMIC_KEY_CHOICES, + self::DYNAMIC_KEY_DESCRIPTION, + self::DYNAMIC_KEY_ID, + self::DYNAMIC_KEY_IS_MULTI_CHOICE, + self::DYNAMIC_KEY_IS_REQUIRED, + self::DYNAMIC_KEY_POSITION, + self::DYNAMIC_KEY_TITLE, + self::DYNAMIC_KEY_VALUE, + self::DYNAMIC_KEY_VALUES, + ]; + } + + public function onNewContext(TransformContext $context) + { + $data = parent::onNewContext($context); + $data['definition'] = null; + $data['value'] = null; + + $source = $context->getSource(); + if (is_array($source) && count($source) > 0) { + $data['definition'] = $source[0]; + + if (count($source) > 1) { + $data['value'] = $source[1]; + } + } + + return $data; + } + + /** + * @param Definition $definition + * @return bool + */ + protected function hasChoices($definition) + { + return in_array($definition['type_group'], ['single', 'multiple'], true); + } +} diff --git a/Transform/Exception.php b/Transform/Exception.php new file mode 100644 index 00000000..b0d5cf02 --- /dev/null +++ b/Transform/Exception.php @@ -0,0 +1,45 @@ +getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_CODE: + return $exception->getCode(); + case self::DYNAMIC_KEY_MESSAGE: + return $exception->getMessage(); + case self::DYNAMIC_KEY_TRACE: + if (!\XF::$debugMode) { + return null; + } + + return explode("\n", $exception->getTraceAsString()); + } + + return null; + } + + public function canView(TransformContext $context) + { + return true; + } + + public function getMappings(TransformContext $context) + { + return [ + self::DYNAMIC_KEY_CODE, + self::DYNAMIC_KEY_MESSAGE, + self::DYNAMIC_KEY_TRACE, + ]; + } +} diff --git a/Transform/Generic.php b/Transform/Generic.php new file mode 100644 index 00000000..6d81e617 --- /dev/null +++ b/Transform/Generic.php @@ -0,0 +1,57 @@ +getSource(); + if (!isset($source[$key])) { + return null; + } + + $value = $source[$key]; + if (!is_array($value) || !is_callable($value)) { + return $value; + } + + return call_user_func($value, $this, $key); + } + + public function canView(TransformContext $context) + { + return true; + } + + public function getMappings(TransformContext $context) + { + $mappings = []; + + $source = $context->getSource(); + if ($source instanceof Entity) { + $primaryKey = $source->structure()->primaryKey; + if (is_string($primaryKey)) { + $mappings[$primaryKey] = $primaryKey; + } elseif (is_array($primaryKey)) { + foreach ($primaryKey as $column) { + if (is_string($column)) { + $mappings[$column] = $column; + } + } + } + } elseif (is_array($source)) { + foreach (array_keys($source) as $key) { + if (is_array($source[$key])) { + $mappings[] = $key; + } else { + $mappings[$key] = $key; + } + } + } + + return $mappings; + } +} diff --git a/Transform/LazyTransformer.php b/Transform/LazyTransformer.php new file mode 100644 index 00000000..c4725479 --- /dev/null +++ b/Transform/LazyTransformer.php @@ -0,0 +1,205 @@ +controller = $controller; + } + + /** + * @param callable $f + * @return void + */ + public function addCallbackFinderPostFetch($f) + { + if ($this->sourceType !== self::SOURCE_TYPE_FINDER) { + throw new \LogicException('Source is not a Finder: ' . $this->sourceType); + } + + $this->callbacksFinderPostFetch[] = $f; + } + + /** + * @param callable $f + * @return void + */ + public function addCallbackPostTransform($f) + { + $this->callbacksPostTransform[] = $f; + } + + /** + * @param callable $f + * @return void + */ + public function addCallbackPreTransform($f) + { + $this->callbacksPreTransform[] = $f; + } + + /** + * @return array|null + */ + public function jsonSerialize() + { + return $this->transform(); + } + + /** + * @return string + */ + public function getLogData() + { + switch ($this->sourceType) { + case 'entity': + /** @var Entity $entity */ + $entity = $this->source; + if (is_string($entity->structure()->primaryKey)) { + $entityId = $entity->getEntityId(); + } else { + $entityId = []; + foreach ($entity->structure()->primaryKey as $primaryKey) { + $entityId[] = $entity->getValue($primaryKey); + } + + $entityId = implode('-', $entityId); + } + + return sprintf( + 'LazyTransformer(%s@%s)', + \XF::stringToClass($entity->structure()->shortName, '%s\Entity\%s'), + $entityId + ); + case 'finder': + /** @var Finder $finder */ + $finder = $this->source; + return sprintf( + 'LazyTransformer(%s) => %s', + \XF::stringToClass($finder->getStructure()->shortName, '%s\Finder\%s'), + $finder->getQuery() + ); + default: + return 'LazyTransformer(' . $this->sourceType . ')'; + } + } + + /** + * @param Entity $entity + * @return void + */ + public function setEntity(Entity $entity) + { + if ($this->source !== null) { + throw new \LogicException('Source has already been set: ' . $this->sourceType); + } + + $this->source = $entity; + $this->sourceType = self::SOURCE_TYPE_ENTITY; + } + + /** + * @param Finder $finder + * @return void + */ + public function setFinder(Finder $finder) + { + if ($this->source !== null) { + throw new \LogicException('Source has already been set: ' . $this->sourceType); + } + + $this->source = $finder; + $this->sourceType = self::SOURCE_TYPE_FINDER; + } + + /** + * @return array|null + */ + public function transform() + { + $controller = $this->controller; + /** @var Transformer $transformer */ + $transformer = $controller->app()->container('api.transformer'); + $context = $controller->params()->getTransformContext(); + + foreach (array_reverse($this->callbacksPreTransform) as $f) { + $context = call_user_func($f, $context); + if ($context === null) { + return null; + } + } + + switch ($this->sourceType) { + case self::SOURCE_TYPE_ENTITY: + /** @var Entity $entity */ + $entity = $this->source; + $data = $transformer->transformEntity($context, null, $entity); + break; + case self::SOURCE_TYPE_FINDER: + /** @var Finder $finder */ + $finder = $this->source; + $data = $transformer->transformFinder($context, null, $finder, function ($entities) { + foreach (array_reverse($this->callbacksFinderPostFetch) as $f) { + $entities = call_user_func($f, $entities); + } + + return $entities; + }); + break; + default: + throw new \LogicException('Unrecognized source type ' . $this->sourceType); + } + + foreach (array_reverse($this->callbacksPostTransform) as $f) { + $data = call_user_func($f, $data); + if ($data === null) { + return null; + } + } + + return $data; + } +} diff --git a/Transform/Selector.php b/Transform/Selector.php new file mode 100644 index 00000000..6e5210c1 --- /dev/null +++ b/Transform/Selector.php @@ -0,0 +1,215 @@ +defaultAction !== self::ACTION_NONE) { + return true; + } + + if (count($this->rules) > 0) { + return true; + } + + return false; + } + + /** + * @param string $key + * @return Selector|null + */ + public function getSubSelector($key) + { + if (!isset($this->rules[$key])) { + return null; + } + + $rulesRef =& $this->rules[$key]; + if (isset($rulesRef['selector'])) { + return $rulesRef['selector']; + } + + $selector = new self(); + $selector->parseRules($rulesRef['excludes'], $rulesRef['includes']); + $rulesRef['selector'] = $selector; + + return $selector; + } + + /** + * @param string|array $exclude + * @param string|array $include + * @return void + */ + public function parseRules($exclude, $include) + { + $this->rules = []; + + $this->parseRulesInclude($include); + $this->parseRulesExclude($exclude); + } + + /** + * @param string $key + * @return bool + */ + public function shouldExcludeField($key) + { + if ($this->defaultAction === self::ACTION_EXCLUDE) { + if (isset($this->rules[$key])) { + $rulesRef =& $this->rules[$key]; + $action = is_string($rulesRef['action']) ? $rulesRef['action'] : $this->defaultAction; + if ($action === self::ACTION_INCLUDE || count($rulesRef['includes']) > 0) { + return false; + } + } + + return true; + } + + if (isset($this->rules[$key])) { + $rulesRef =& $this->rules[$key]; + $action = is_string($rulesRef['action']) ? $rulesRef['action'] : $this->defaultAction; + if ($action === self::ACTION_EXCLUDE && count($rulesRef['includes']) === 0) { + return true; + } + } + + return false; + } + + /** + * @param string $key + * @return bool + */ + public function shouldIncludeField($key) + { + if ($this->defaultAction !== self::ACTION_NONE) { + return !$this->shouldExcludeField($key); + } + + if (isset($this->rules[$key])) { + $rulesRef =& $this->rules[$key]; + $action = is_string($rulesRef['action']) ? $rulesRef['action'] : $this->defaultAction; + if ($action === self::ACTION_INCLUDE || count($rulesRef['includes']) > 0) { + return true; + } + } + + return false; + } + + /** + * @param string $key + * @return void + */ + protected function makeSureRuleExists($key) + { + if (isset($this->rules[$key])) { + return; + } + + $this->rules[$key] = [ + 'action' => null, + 'excludes' => [], + 'includes' => [], + ]; + } + + /** + * @param string|array $rules + * @return void + */ + protected function parseRulesExclude($rules) + { + if (!is_array($rules)) { + $rules = explode(',', $rules); + } + + foreach ($rules as $rule) { + $rule = trim($rule); + if ($rule === '') { + continue; + } + + $parts = explode('.', $rule, 2); + $key = $parts[0]; + if ($key === '') { + continue; + } + + if ($key === '*') { + $this->defaultAction = self::ACTION_EXCLUDE; + continue; + } + + $this->makeSureRuleExists($key); + + if (isset($parts[1])) { + $this->rules[$key]['excludes'][] = $parts[1]; + } else { + $this->rules[$key]['action'] = self::ACTION_EXCLUDE; + } + } + } + + /** + * @param string|array $rules + * @return void + */ + protected function parseRulesInclude($rules) + { + if (!is_array($rules)) { + $rules = explode(',', $rules); + } + + foreach ($rules as $rule) { + $rule = trim($rule); + if ($rule === '') { + continue; + } + + $parts = explode('.', $rule, 2); + $key = $parts[0]; + if ($key === '') { + continue; + } + + if ($key === '*') { + $this->defaultAction = self::ACTION_INCLUDE; + continue; + } + + if ($this->defaultAction === self::ACTION_NONE) { + $this->defaultAction = self::ACTION_EXCLUDE; + } + + $this->makeSureRuleExists($key); + $this->rules[$key]['action'] = self::ACTION_INCLUDE; + + if (isset($parts[1])) { + $this->rules[$key]['includes'][] = $parts[1]; + } + } + } +} diff --git a/Transform/TransformContext.php b/Transform/TransformContext.php new file mode 100644 index 00000000..54248e9c --- /dev/null +++ b/Transform/TransformContext.php @@ -0,0 +1,276 @@ +handler = $handler; + $this->source = $source; + $this->selector = $selector; + + $this->rootContext = $this; + } + + /** + * @param string $key + * @return mixed|null + */ + public function data($key) + { + if (!isset($this->contextData[$key])) { + return null; + } + + return $this->contextData[$key]; + } + + /** + * @return AbstractHandler|null + */ + public function getHandler() + { + return $this->handler; + } + + /** + * @return array + */ + public function getOnTransformedCallbacks() + { + return $this->rootContext->onTransformedCallbacks; + } + + /** + * @return array + */ + public function getOnTransformEntitiesCallbacks() + { + return $this->rootContext->onTransformEntitiesCallbacks; + } + + /** + * @return array + */ + public function getOnTransformFinderCallbacks() + { + return $this->rootContext->onTransformFinderCallbacks; + } + + /** + * @return AbstractHandler|null + */ + public function getParentHandler() + { + if ($this->parentContext === null) { + return null; + } + + return $this->parentContext->getHandler(); + } + + /** + * @return mixed|null + */ + public function getParentSource() + { + if ($this->parentContext === null) { + return null; + } + + return $this->parentContext->getSource(); + } + + /** + * @param string $key + * @return mixed|null + */ + public function getParentSourceValue($key) + { + if ($this->parentContext === null) { + return null; + } + + return $this->parentContext->getSourceValue($key); + } + + /** + * @return mixed|null + */ + public function getSource() + { + return $this->source; + } + + /** + * @param string $key + * @return mixed|null + */ + public function getSourceValue($key) + { + return $this->source !== null ? $this->source[$key] : null; + } + + /** + * @param string|null $key + * @param AbstractHandler|null $subHandler + * @param mixed|null $subSource + * @return TransformContext + */ + public function getSubContext($key = null, $subHandler = null, $subSource = null) + { + $subSelector = $this->selector; + if ($subSelector !== null && $key !== null) { + $subSelector = $subSelector->getSubSelector($key); + } + + $subContext = new TransformContext($subHandler, $subSource, $subSelector); + $subContext->parentContext = $this; + $subContext->rootContext = $this->rootContext; + + if ($key === null) { + $subContext->setData($this->contextData); + } + + return $subContext; + } + + /** + * @param string $handlerType + * @return Selector + */ + public function makeSureSelectorIsNotNull($handlerType) + { + if ($this->selector === null) { + $this->selector = $this->getSelectorDefault(); + } + + if (!$this->selector->hasRules()) { + if (isset($this->rootContext->selectorByTypes[$handlerType])) { + $this->selector = $this->rootContext->selectorByTypes[$handlerType]; + } + } else { + if (!isset($this->rootContext->selectorByTypes[$handlerType])) { + $this->rootContext->selectorByTypes[$handlerType] = $this->selector; + } + } + + return $this->selector; + } + + /** + * @param string $key + * @return bool + */ + public function selectorShouldExcludeField($key) + { + return $this->selector !== null ? $this->selector->shouldExcludeField($key) : false; + } + + /** + * @param string $key + * @return bool + */ + public function selectorShouldIncludeField($key) + { + return $this->selector !== null ? $this->selector->shouldIncludeField($key) : false; + } + + /** + * @param array|mixed $data + * @param mixed|null $key + * @return void + */ + public function setData($data, $key = null) + { + if ($key === null) { + if (!is_array($data)) { + throw new \InvalidArgumentException('$data is not an array'); + } + + $this->contextData = array_merge($this->contextData, $data); + } else { + $this->contextData[$key] = $data; + } + } + + /** + * @return Selector + */ + protected function getSelectorDefault() + { + $rootContext = $this->rootContext; + + if ($rootContext->selectorDefault === null) { + $rootContext->selectorDefault = new Selector(); + } + + return $rootContext->selectorDefault; + } +} diff --git a/Transformer.php b/Transformer.php new file mode 100644 index 00000000..88e61f53 --- /dev/null +++ b/Transformer.php @@ -0,0 +1,329 @@ +app = $app; + $this->container = new Container(); + + $this->container->factory('handler', function ($type) { + $class = \XF::stringToClass($type, '%s\Api\Transform\%s'); + $class = $this->app->extendClass($class); + + if (!class_exists($class)) { + /** @var Modules $modules */ + $modules = $this->app->data('Xfrocks\Api:Modules'); + $class = $modules->getTransformerClass($type); + $class = $this->app->extendClass($class); + } + + if (!class_exists($class)) { + $class = 'Xfrocks\Api\Transform\Generic'; + } + + return new $class($this->app, $this, $type); + }); + } + + /** + * @param string $type + * @return AbstractHandler + */ + public function handler($type = '') + { + return $this->container->create('handler', $type); + } + + /** + * @param TransformContext $context + * @param string|null $key + * @param array $values + * @return array + */ + public function transformArray($context, $key, array $values) + { + $subContext = $context->getSubContext($key, $this->handler(), $values); + return $this->transform($subContext); + } + + /** + * @param \XF\Mvc\Reply\AbstractReply $reply + * @return array + */ + public function transformBatchJobReply($reply) + { + /** @var \Xfrocks\Api\Transform\BatchJobReply $handler */ + $handler = $this->handler('Xfrocks:BatchJobReply'); + $context = new TransformContext($handler, $reply); + return $this->transform($context); + } + + /** + * @param TransformContext $context + * @param \XF\CustomField\DefinitionSet $set + * @param string $key + * @return array + */ + public function transformCustomFieldDefinitionSet($context, $set, $key = AbstractHandler::DYNAMIC_KEY_FIELDS) + { + /** @var \Xfrocks\Api\Transform\CustomField $subHandler */ + $subHandler = $this->handler('Xfrocks:CustomField'); + + $data = []; + foreach ($set->getIterator() as $definition) { + $subContext = $context->getSubContext($key, $subHandler, [$definition]); + $data[] = $this->transform($subContext); + } + + return $data; + } + + /** + * @param TransformContext $context + * @param \XF\CustomField\Set $set + * @param string $key + * @return array + */ + public function transformCustomFieldSet($context, $set, $key = AbstractHandler::DYNAMIC_KEY_FIELDS) + { + $definitionSet = $set->getDefinitionSet(); + /** @var \Xfrocks\Api\Transform\CustomField $subHandler */ + $subHandler = $this->handler('Xfrocks:CustomField'); + + $data = []; + foreach ($set->getIterator() as $field => $value) { + if (!isset($definitionSet[$field])) { + continue; + } + $definition = $definitionSet[$field]; + + $subContext = $context->getSubContext($key, $subHandler, [$definition, $value]); + $data[] = $this->transform($subContext); + } + + return $data; + } + + /** + * @param TransformContext $context + * @param string|null $key + * @param Entity $subEntity + * @return array + */ + public function transformEntity($context, $key, $subEntity) + { + $subHandler = $this->handler($subEntity->structure()->shortName); + $subContext = $context->getSubContext($key, $subHandler, $subEntity); + return $this->transform($subContext); + } + + /** + * @param TransformContext $context + * @param string|null $key + * @param Entity $entity + * @param string $relationKey + * @return array + */ + public function transformEntityRelation($context, $key, $entity, $relationKey) + { + $entityStructure = $entity->structure(); + if (!isset($entityStructure->relations[$relationKey])) { + return []; + } + + $relationConfig = $entityStructure->relations[$relationKey]; + if (!is_array($relationConfig) || + !isset($relationConfig['type']) || + !isset($relationConfig['entity']) + ) { + return []; + } + + $relationData = $entity->getRelation($relationKey); + if ($relationConfig['type'] === Entity::TO_ONE) { + /** @var Entity $subEntity */ + $subEntity = $relationData; + return $this->transformEntity($context, $key, $subEntity); + } + + $subHandler = $this->handler($relationConfig['entity']); + + $data = []; + /** @var Entity[] $subEntities */ + $subEntities = $relationData; + $subContextTemp = $context->getSubContext($key, null, null); + $subEntities = $subHandler->onTransformEntities($subContextTemp, $subEntities); + + foreach ($subEntities as $subEntity) { + $subContext = $context->getSubContext($key, $subHandler, $subEntity); + $subEntityData = $this->transform($subContext); + if (count($subEntityData) > 0) { + $data[] = $subEntityData; + } + } + + return $data; + } + + /** + * @param \Exception $exception + * @return array + */ + public function transformException($exception) + { + $handler = $this->handler('Xfrocks:Exception'); + $context = new TransformContext($handler, $exception); + return $this->transform($context); + } + + /** + * @param TransformContext $context + * @param string|null $key + * @param \XF\Mvc\Entity\Finder $finder + * @param callable|null $postFetchCallback + * @return array + */ + public function transformFinder($context, $key, $finder, $postFetchCallback = null) + { + $handler = $this->handler($finder->getStructure()->shortName); + $finder = $handler->onTransformFinder($context, $finder); + + $entities = $finder->fetch(); + if ($postFetchCallback !== null) { + $entities = call_user_func($postFetchCallback, $entities); + } + if ($entities instanceof \XF\Mvc\Entity\ArrayCollection) { + $entities = $entities->toArray(); + } + $entities = $handler->onTransformEntities($context, $entities); + + $data = []; + foreach ($entities as $entity) { + $entityContext = $context->getSubContext(null, $handler, $entity); + $entityData = $this->transform($entityContext); + if (count($entityData) === 0) { + continue; + } + + $data[] = $entityData; + } + + return $data; + } + + /** + * @param TransformContext $context + * @param mixed $tags + * @return array + */ + public function transformTags($context, $tags) + { + if (!is_array($tags) || count($tags) === 0) { + return []; + } + + $data = []; + + foreach ($tags as $tagId => $tag) { + $data[strval($tagId)] = $tag['tag']; + } + + return $data; + } + + /** + * @param TransformContext $context + * @param string $key + * @param array|null $values + * @param array $data + * @return void + */ + protected function addArrayToData($context, $key, $values, array &$data) + { + if (!is_array($values) || count($values) === 0) { + return; + } + + $transformed = $this->transformArray($context, $key, $values); + if (count($transformed) === 0) { + return; + } + + $data[$key] = $transformed; + } + + /** + * @param TransformContext $context + * @return array + */ + protected function transform($context) + { + $data = []; + $handler = $context->getHandler(); + if ($handler === null) { + return $data; + } + + $contextData = $handler->onNewContext($context); + $context->setData($contextData); + + if (!$handler->canView($context)) { + return $data; + } + + $handler->addAttachmentsToQueuedEntities(); + + $mappings = $handler->getMappings($context); + foreach ($mappings as $key => $mapping) { + if ($context->selectorShouldExcludeField($mapping)) { + continue; + } + + $value = null; + if (is_string($key)) { + $value = $context->getSourceValue($key); + } else { + $value = $handler->calculateDynamicValue($context, $mapping); + } + if ($value !== null) { + $data[$mapping] = $value; + } + } + + if (!$context->selectorShouldExcludeField(AbstractHandler::KEY_LINKS)) { + $links = $handler->collectLinks($context); + $this->addArrayToData($context, AbstractHandler::KEY_LINKS, $links, $data); + } + + if (!$context->selectorShouldExcludeField(AbstractHandler::KEY_PERMISSIONS)) { + $permissions = $handler->collectPermissions($context); + $this->addArrayToData($context, AbstractHandler::KEY_PERMISSIONS, $permissions, $data); + } + + $handler->onTransformed($context, $data); + + return $data; + } +} diff --git a/Util/Cors.php b/Util/Cors.php new file mode 100644 index 00000000..1074a109 --- /dev/null +++ b/Util/Cors.php @@ -0,0 +1,38 @@ +options()->bdApi_cors) { + return; + } + + $request = $app->request(); + + $origin = $request->getServer('HTTP_ORIGIN'); + if ($origin !== false) { + $response->header('Access-Control-Allow-Origin', $origin, true); + $response->header('Access-Control-Allow-Credentials', 'true', true); + } else { + $response->header('Access-Control-Allow-Origin', '*', true); + } + + $method = $request->getServer('HTTP_ACCESS_CONTROL_REQUEST_METHOD'); + if ($method !== false) { + $response->header('Access-Control-Allow-Methods', $method, true); + } + + $headers = $request->getServer('HTTP_ACCESS_CONTROL_REQUEST_HEADERS'); + if ($headers !== false) { + $response->header('Access-Control-Allow-Headers', $headers, true); + } + } +} diff --git a/Util/Crypt.php b/Util/Crypt.php new file mode 100644 index 00000000..a9ee7d78 --- /dev/null +++ b/Util/Crypt.php @@ -0,0 +1,220 @@ +config('globalSalt'); + return self::encrypt($data, $algo, $key); + } + + /** + * @param string $data + * @param int $timestamp + * @return string + * @throws PrintableException + */ + public static function decryptTypeOne($data, $timestamp) + { + if ($timestamp < \XF::$time) { + throw new \InvalidArgumentException('$timestamp has expired'); + } + + $algo = self::getDefaultAlgo(); + $key = $timestamp . \XF::app()->config('globalSalt'); + $decrypted = self::decrypt($data, $algo, $key); + + if ($decrypted === false || $decrypted === '') { + throw new \LogicException('$data could not be decrypted'); + } + + return $decrypted; + } + + /** + * @return string + * @throws PrintableException + */ + protected static function getKey() + { + /** @var mixed $session */ + $session = \XF::app()->session(); + $callable = [$session, 'getToken']; + + $clientSecret = ''; + if (is_callable($callable)) { + /** @var Token|null $token */ + $token = call_user_func($callable); + $client = $token !== null ? $token->Client : null; + $clientSecret = $client !== null ? $client->client_secret : ''; + } + + if ($clientSecret === '') { + throw new PrintableException(\XF::phrase('bdapi_request_must_authorize_to_encrypt')); + } + + return $clientSecret; + } + + /** + * @param string $data + * @param string $key + * @return string|false + */ + protected static function aes128Encrypt($data, $key) + { + $key = md5($key, true); + return openssl_encrypt($data, self::OPENSSL_METHOD_AES128, $key, self::OPENSSL_OPT_RAW_DATA); + } + + /** + * @param string $data + * @param string $key + * @return string|false + */ + protected static function aes128Decrypt($data, $key) + { + $key = md5($key, true); + return openssl_decrypt($data, self::OPENSSL_METHOD_AES128, $key, self::OPENSSL_OPT_RAW_DATA); + } + + /** + * @param string $data + * @param string $key + * @return string|false + */ + protected static function aes256Encrypt($data, $key) + { + $ivLength = openssl_cipher_iv_length(self::OPENSSL_METHOD_AES256); + if ($ivLength === false) { + throw new \InvalidArgumentException('Cannot encrypt data'); + } + + $iv = openssl_random_pseudo_bytes($ivLength); + if ($iv === false) { + throw new \InvalidArgumentException('Cannot encrypt data'); + } + + $encrypted = openssl_encrypt($data, self::OPENSSL_METHOD_AES256, $key, self::OPENSSL_OPT_RAW_DATA, $iv); + if ($encrypted === false) { + return false; + } + + return self::ALGO_AES_256 . $iv . $encrypted; + } + + /** + * @param string $data + * @param string $key + * @return string|false + */ + protected static function aes256Decrypt($data, $key) + { + /** @var int|false $prefixLength */ + $prefixLength = mb_strlen(self::ALGO_AES_256, '8bit'); + if ($prefixLength === false) { + throw new \InvalidArgumentException('Cannot decrypt data'); + } + + $prefix = mb_substr($data, 0, $prefixLength); + if ($prefix === self::ALGO_AES_256) { + $ivLength = openssl_cipher_iv_length(self::OPENSSL_METHOD_AES256); + if ($ivLength === false) { + throw new \InvalidArgumentException('Cannot decrypt data'); + } + + $iv = mb_substr($data, $prefixLength, $ivLength, '8bit'); + $encrypted = mb_substr($data, $prefixLength + $ivLength, null, '8bit'); + + return openssl_decrypt($encrypted, self::OPENSSL_METHOD_AES256, $key, self::OPENSSL_OPT_RAW_DATA, $iv); + } + + return false; + } +} diff --git a/Util/Html.php b/Util/Html.php new file mode 100644 index 00000000..af08e5b5 --- /dev/null +++ b/Util/Html.php @@ -0,0 +1,46 @@ +client_secret); + $ott = sprintf('%d,%d,%s,%s', $userId, $timestamp, $once, $client->client_id); + + return $ott; + } + + /** + * @param int $userId + * @param int $timestamp + * @param string $tokenText + * @param string $clientSecret + * @return string + */ + public static function generateOnce($userId, $timestamp, $tokenText, $clientSecret) + { + return md5($userId . $timestamp . $tokenText . $clientSecret); + } + + /** + * @param Server $server + * @param string $ott + * @return Token|null + */ + public static function parse($server, $ott) + { + if (preg_match('/^(\d+),(\d+),(.{32}),(.+)$/', $ott, $matches) !== 1) { + return null; + } + + $userId = intval($matches[1]); + $timestamp = intval($matches[2]); + $once = $matches[3]; + $clientId = $matches[4]; + + if ($timestamp < time()) { + return null; + } + + $app = \XF::app(); + /** @var Token $token */ + $token = $app->em()->create('Xfrocks\Api:Token'); + $token->client_id = $clientId; + $token->token_text = $ott; + $token->expire_date = $timestamp; + $token->user_id = $userId; + $token->setScopes($server->getScopeDefaults()); + + $client = $token->Client; + if ($client === null) { + return null; + } + + if ($userId === 0) { + if ($once !== self::generateOnce($userId, $timestamp, '', $client->client_secret)) { + return null; + } + return $token; + } + + /** @var \XF\Repository\User $userRepo */ + $userRepo = $app->repository('XF:User'); + $userWith = $userRepo->getVisitorWith(); + $with = array_map(function ($with) { + return 'User.' . $with; + }, $userWith); + + $userTokenTexts = $app->finder('Xfrocks\Api:Token') + ->where('client_id', $client->client_id) + ->where('user_id', $userId) + ->with($with) + ->with('Client', true) + ->pluckFrom('expire_date', 'token_text') + ->fetch(); + + foreach ($userTokenTexts as $userTokenText => $expireDate) { + if ($once === self::generateOnce($userId, $timestamp, $userTokenText, $client->client_secret)) { + $token->expire_date = min($token->expire_date, $expireDate); + return $token; + } + } + + return null; + } +} diff --git a/Util/PageNav.php b/Util/PageNav.php new file mode 100644 index 00000000..1887ba89 --- /dev/null +++ b/Util/PageNav.php @@ -0,0 +1,99 @@ +getFilteredLimit(); + $filteredPage = $params->getFilteredPage(); + if (!is_array($filteredLimit) || !is_array($filteredPage)) { + return null; + } + + $limit = $filteredLimit['value']; + if ($total <= $limit) { + return null; + } + + $keyLinks = self::mapKey($config, 'links'); + $keyNext = self::mapKey($config, 'next'); + $keyPages = self::mapKey($config, 'pages'); + $keyPrev = self::mapKey($config, 'prev'); + $pageNav = []; + $pageNav[$keyPages] = ceil($total / $limit); + $pageMax = $filteredPage['max']; + if ($pageMax > 0) { + $pageNav[$keyPages] = min($pageNav[$keyPages], $pageMax); + } + if ($pageNav[$keyPages] < 2) { + return null; + } + + $page = $filteredPage['value']; + $keyPage = $filteredPage['key']; + $pageNav[$keyPage] = max(1, min($page, $pageNav[$keyPages])); + + $linkParams = []; + foreach ($params->getFilteredValues() as $linkParamKey => $linkParamValue) { + $paramFiltered = $params->getFiltered($linkParamKey); + if (!is_array($paramFiltered)) { + continue; + } + if ($linkParamValue === $paramFiltered['default']) { + continue; + } + + $linkParams[$linkParamKey] = $linkParamValue; + } + ksort($linkParams); + + if ($pageNav[$keyPage] > 1) { + $prevLinkParams = array_merge($linkParams, [$keyPage => $pageNav[$keyPage] - 1]); + $pageNav[$keyPrev] = $params->getController()->buildApiLink($link, $linkData, $prevLinkParams); + } + + if ($pageNav[$keyPage] < $pageNav['pages']) { + $nextLinkParams = array_merge($linkParams, [$keyPage => $pageNav[$keyPage] + 1]); + $pageNav[$keyNext] = $params->getController()->buildApiLink($link, $linkData, $nextLinkParams); + } + + if (!isset($data[$keyLinks])) { + $data[$keyLinks] = []; + } + $data[$keyLinks] = array_merge($data[$keyLinks], $pageNav); + + return $pageNav; + } + + /** + * @param array $config + * @param string $key + * @return string + */ + protected static function mapKey(array $config, $key) + { + if (!isset($config['keys']) || !isset($config['keys'][$key])) { + return $key; + } + return $config['keys'][$key]; + } +} diff --git a/Util/ParentFinder.php b/Util/ParentFinder.php new file mode 100644 index 00000000..b76dd677 --- /dev/null +++ b/Util/ParentFinder.php @@ -0,0 +1,81 @@ +getStructure(); + + if (!isset($structure->relations[$relationKey])) { + throw new \InvalidArgumentException( + sprintf('%s does not have relation %s', $structure->shortName, $relationKey) + ); + } + + $relationConfig = $structure->relations[$relationKey]; + if (!is_array($relationConfig) || !isset($relationConfig['entity'])) { + throw new \InvalidArgumentException( + sprintf('Relation %s.%s has invalid config', $structure->shortName, $relationKey) + ); + } + + $this->finder = $finder; + $this->parentFinder = self::getParentFinderOfType($finder, $relationConfig['entity']); + $this->relationKey = $relationKey; + } + + /** + * @param string $name + * @return void + */ + public function with($name) + { + if ($this->parentFinder !== null) { + $this->parentFinder->with($name); + } else { + $this->finder->with(sprintf('%s.%s', $this->relationKey, $name)); + } + } + + /** + * @param \XF\Mvc\Entity\Finder $finder + * @param string $shortName + * @return \XF\Mvc\Entity\Finder|null + */ + public static function getParentFinderOfType($finder, $shortName) + { + while (true) { + if ($finder->getStructure()->shortName === $shortName) { + return $finder; + } + + $finder = $finder->getParentFinder(); + if (!$finder) { + break; + } + } + + return null; + } +} diff --git a/Util/Token.php b/Util/Token.php new file mode 100644 index 00000000..a9b1669a --- /dev/null +++ b/Util/Token.php @@ -0,0 +1,46 @@ +getScopes() as $scope) { + $scopeIds[] = $scope->getId(); + } + + // TODO: find a better way to keep token response data in sync with BearerWithScope + $response = [ + 'access_token' => $accessToken->getId(), + 'expires_in' => $accessToken->getExpireTime() - time(), + 'scope' => implode(Listener::$scopeDelimiter, $scopeIds), + 'token_type' => 'Bearer', + ]; + + if ($refreshToken !== null) { + $response['refresh_token'] = $refreshToken->getId(); + } + + /** @var \League\OAuth2\Server\Entity\SessionEntity|null $session */ + $session = $accessToken->getSession(); + if ($session !== null && $session->getOwnerType() === SessionStorage::OWNER_TYPE_USER) { + $response['user_id'] = intval($session->getOwnerId()); + } + + return $response; + } +} diff --git a/Util/Tree.php b/Util/Tree.php new file mode 100644 index 00000000..a809d989 --- /dev/null +++ b/Util/Tree.php @@ -0,0 +1,28 @@ +childIds($ids[$i]); + $i++; + + if (count($childIds) === 0) { + continue; + } + + $ids = array_merge($ids, $childIds); + } + + return $ids; + } +} diff --git a/View/Asset/Sdk.php b/View/Asset/Sdk.php new file mode 100644 index 00000000..c675aff3 --- /dev/null +++ b/View/Asset/Sdk.php @@ -0,0 +1,17 @@ +response->contentType('application/x-javascript'); + $this->response->header('Cache-Control', 'public, max-age=31536000'); + + return $this->params['sdk']; + } +} diff --git a/View/User/DefaultAvatar.php b/View/User/DefaultAvatar.php new file mode 100644 index 00000000..b80dd6a8 --- /dev/null +++ b/View/User/DefaultAvatar.php @@ -0,0 +1,79 @@ +params['user']; + + /** @var Templater $templater */ + $templater = $app->templater(); + $defaultAvatarStyling = $templater->getDefaultAvatarStylingForApi($user->username); + + $manager = $app->imageManager(); + /** @var DriverInterface $image */ + $image = $manager->createImage($this->params['size'], $this->params['size']); + + $bgColor = Html::parseColor($defaultAvatarStyling['bgColor']); + $image->setBackgroundColorForApi($bgColor[0], $bgColor[1], $bgColor[2]); + + $color = Html::parseColor($defaultAvatarStyling['color']); + $font = Html::parseFontFamily($templater->func('property', ['avatarDynamicFont'])); + $percent = intval($templater->func('property', ['avatarDynamicTextPercent'])); + $text = $defaultAvatarStyling['innerContent']; + + $fontFile = $this->findTtfFontPath($font); + if ($fontFile !== false) { + $image->putTextAtCenterForApi($percent, $color[0], $color[1], $color[2], $fontFile, $text); + } + + $this->response->contentType('image/png', ''); + $this->response->header('Cache-Control', 'public, max-age=31536000'); + + return '' . $image->output(IMAGETYPE_PNG); + } + + /** + * @param string $font + * @return string|false + */ + protected function findTtfFontPath($font) + { + static $candidates = null; + + if ($candidates === null) { + /** + * Font installation guide for Debian: + * + * 1. Make sure contrib is enabled in /etc/apt/sources.list + * 2. apt-get update + * 3. apt-get install -y ttf-mscorefonts-installer + */ + $candidates = [ + '/usr/share/fonts/truetype', + '/usr/share/fonts/truetype/msttcorefonts', + \XF::getRootDirectory() . DIRECTORY_SEPARATOR . 'styles' . DIRECTORY_SEPARATOR . 'fonts', + ]; + } + + foreach ($candidates as $candidate) { + $fontFile = $candidate . DIRECTORY_SEPARATOR . $font . '.ttf'; + if (file_exists($fontFile)) { + return $fontFile; + } + } + + return false; + } +} diff --git a/XF/ApiOnly/BbCode/Renderer/Html.php b/XF/ApiOnly/BbCode/Renderer/Html.php new file mode 100644 index 00000000..ee80ba19 --- /dev/null +++ b/XF/ApiOnly/BbCode/Renderer/Html.php @@ -0,0 +1,133 @@ +getTemplater(); + $templater->requiredExternalsReset(); + + $rendered = parent::renderAst($ast, $rules, $options); + + $requiredExternalsHtml = $templater->requiredExternalsGetHtml(); + if (strlen($requiredExternalsHtml) > 0) { + $rendered = sprintf('%s', $requiredExternalsHtml, $rendered); + } + + return $rendered; + } + + /** + * @param array $children + * @param mixed $option + * @param array $tag + * @param array $options + * @return string + * @throws \XF\PrintableException + */ + public function renderTagMedia(array $children, $option, array $tag, array $options) + { + if ($this->XfrocksApiChr === null) { + return parent::renderTagMedia($children, $option, $tag, $options); + } + + /** @var Templater $templater */ + $templater = $this->getTemplater(); + $backup = $templater->requiredExternalsReset(); + + $html = parent::renderTagMedia($children, $option, $tag, $options); + $requiredExternals = $templater->requiredExternalsReset($backup); + + return $this->renderXfrocksApiChr($html, $requiredExternals); + } + + /** + * @param string $html + * @param array $requiredExternals + * @return string + * @throws \XF\PrintableException + */ + public function renderXfrocksApiChr($html, array $requiredExternals) + { + $requiredExternalsTrimmed = []; + foreach ($requiredExternals as $key => $value) { + if (is_array($value) && count($value) === 0) { + continue; + } + $requiredExternalsTrimmed[$key] = $value; + } + + if (count($requiredExternalsTrimmed) === 0 && stripos($html, ''; + } + } + + $inlineJs = $this->getInlineJs(); + if (count($inlineJs) > 0) { + foreach ($inlineJs as $inline) { + $html .= ""; + } + } + + return $html; + } + + public function requiredExternalsReset(array $values = null) + { + $backup = []; + + foreach (self::$requiredExternalKeys as $key) { + // @phpstan-ignore-next-line + $backup[$key] = $this->$key; + // @phpstan-ignore-next-line + $this->$key = $values != null ? $values[$key] : []; + } + + return $backup; + } + + private static function _addDimensionsBySrc($html, $src, $height, $width) + { + if (substr_count($html, $src) !== 1) { + return $html; + } + + $html = str_replace($src, $src . " width=\"$width\"", $html); + $html = str_replace($src, $src . " height=\"$height\"", $html); + + return $html; + } +} + +if (false) { + // @codingStandardsIgnoreLine + class XFCP_Templater extends \XF\Template\Templater + { + // extension hint + } +} diff --git a/XF/Entity/Post.php b/XF/Entity/Post.php new file mode 100644 index 00000000..b6458ce6 --- /dev/null +++ b/XF/Entity/Post.php @@ -0,0 +1,45 @@ +repository('Xfrocks\Api:Subscription'); + + if ($this->isInsert()) { + $subRepo->pingThreadPost('insert', $this); + } elseif ($this->isChanged('message_state')) { + if ($this->message_state === 'visible') { + $subRepo->pingThreadPost('insert', $this); + } elseif ($this->getExistingValue('message') === 'visible') { + $subRepo->pingThreadPost('delete', $this); + } + } else { + $subRepo->pingThreadPost('update', $this); + } + } + + /** + * @return void + */ + protected function _postDelete() + { + parent::_postDelete(); + + /** @var Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + if ($this->message_state === 'visible') { + $subRepo->pingThreadPost('delete', $this); + } + } +} diff --git a/XF/Entity/Thread.php b/XF/Entity/Thread.php new file mode 100644 index 00000000..0b23d6d1 --- /dev/null +++ b/XF/Entity/Thread.php @@ -0,0 +1,44 @@ +bdApi_subscriptionThreadPost; + /** @var string $subColumn */ + $subColumn = $options->bdApi_subscriptionColumnThreadPost; + + if ($sub > 0 && $subColumn !== '') { + $structure->columns[$subColumn] = ['type' => self::SERIALIZED_ARRAY, 'default' => []]; + } + + return $structure; + } + + /** + * @return void + */ + protected function _postDelete() + { + parent::_postDelete(); + + /** @var int $sub */ + $sub = $this->app()->options()->bdApi_subscriptionThreadPost; + + if ($sub > 0) { + /** @var Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + $subRepo->deleteSubscriptionsForTopic(Subscription::TYPE_THREAD_POST, $this->thread_id); + } + } +} diff --git a/XF/Entity/User.php b/XF/Entity/User.php new file mode 100644 index 00000000..23513866 --- /dev/null +++ b/XF/Entity/User.php @@ -0,0 +1,50 @@ +hasPermission('general', 'bdApi_clientNew')) { + return true; + } + + if ($this->is_admin) { + return true; + } + + return false; + } + + /** + * @return void + */ + protected function _postSave() + { + parent::_postSave(); + + if ($this->isInsert()) { + /** @var Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + $subRepo->pingUser('insert', $this); + } + } + + /** + * @return void + */ + protected function _postDelete() + { + parent::_postDelete(); + + /** @var Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + $subRepo->pingUser('delete', $this); + } +} diff --git a/XF/Entity/UserAlert.php b/XF/Entity/UserAlert.php new file mode 100644 index 00000000..36ee511a --- /dev/null +++ b/XF/Entity/UserAlert.php @@ -0,0 +1,56 @@ +repository('Xfrocks\Api:Subscription'); + + if (Subscription::getSubOption(Subscription::TYPE_NOTIFICATION)) { + if ($this->alerted_user_id > 0) { + /** @var string $subColumn */ + $subColumn = \XF::options()->bdApi_subscriptionColumnUserNotification; + if (!isset($userOptions[$this->alerted_user_id])) { + $userOptions[$this->alerted_user_id] = $this->db()->fetchOne(' + SELECT `' . $subColumn . '` + FROM `xf_user_option` + WHERE user_id = ? + ', $this->alerted_user_id); + } + + $option = $userOptions[$this->alerted_user_id]; + if (is_string($option) && strlen($option) > 0) { + $option = Php::safeUnserialize($option); + } + + if (!is_array($option)) { + $option = []; + } + } else { + $option = $subRepo->getClientSubscriptionsData(); + } + + if (count($option) > 0) { + $subRepo->ping( + $option, + 'insert', + Subscription::TYPE_NOTIFICATION, + $this->alert_id + ); + } + } + } +} diff --git a/XF/Entity/UserOption.php b/XF/Entity/UserOption.php new file mode 100644 index 00000000..de7bee27 --- /dev/null +++ b/XF/Entity/UserOption.php @@ -0,0 +1,30 @@ +bdApi_subscriptionColumnUser; + /** @var string $userNotifyColumn */ + $userNotifyColumn = $options->bdApi_subscriptionColumnUserNotification; + + if ($options->bdApi_subscriptionUser && $userColumn !== '') { + $structure->columns[$userColumn] = ['type' => self::SERIALIZED_ARRAY, 'default' => []]; + } + + if ($options->bdApi_subscriptionUserNotification && $userNotifyColumn !== '') { + $structure->columns[$userNotifyColumn] = ['type' => self::SERIALIZED_ARRAY, 'default' => []]; + } + + return $structure; + } +} diff --git a/XF/Pub/Controller/Account.php b/XF/Pub/Controller/Account.php new file mode 100644 index 00000000..58187dcf --- /dev/null +++ b/XF/Pub/Controller/Account.php @@ -0,0 +1,414 @@ +getApiClientRepo()->findUserClients($visitor->user_id) + ->with(['User']) + ->fetch(); + + $userScopes = $this->finder('Xfrocks\Api:UserScope') + ->with('Client', true) + ->where('user_id', $visitor->user_id) + ->order('accept_date', 'DESC') + ->fetch(); + + $tokens = $this->finder('Xfrocks\Api:Token') + ->where('user_id', $visitor->user_id) + ->fetch(); + + $userScopesByClientIds = []; + $scopesPhrase = []; + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + + /** @var UserScope $userScope */ + foreach ($userScopes as $userScope) { + if (!isset($userScopesByClientIds[$userScope->client_id])) { + $userScopesByClientIds[$userScope->client_id] = [ + 'last_issue_date' => 0, + 'user_scopes' => [], + 'client' => $userScope->Client, + ]; + } + + $userScopesByClientIds[$userScope->client_id]['last_issue_date'] = \max( + $userScopesByClientIds[$userScope->client_id]['last_issue_date'], + $userScope->accept_date + ); + $userScopesByClientIds[$userScope->client_id]['user_scopes'][$userScope->scope] = $userScope; + $scopesPhrase[$userScope->scope] = $apiServer->getScopeDescription($userScope->scope); + } + + /** @var Token $token */ + foreach ($tokens as $token) { + if (!isset($userScopesByClientIds[$token->client_id])) { + continue; + } + + $userScopesByClientIds[$token->client_id]['last_issue_date'] = \max( + $userScopesByClientIds[$token->client_id]['last_issue_date'], + $token->issue_date + ); + } + + $viewParams = [ + 'clients' => $clients, + 'userScopesClientIds' => $userScopesByClientIds, + 'scopesPhrase' => $scopesPhrase, + ]; + + $view = $this->view('Xfrocks\Api:Account\Api\Index', 'bdapi_account_api', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + public function actionApiUpdateScope() + { + $visitor = \XF::visitor(); + $clientId = $this->filter('client_id', 'str'); + /** @var Client $client */ + $client = $this->assertRecordExists('Xfrocks\Api:Client', $clientId); + + /** @var \Xfrocks\Api\Repository\UserScope $userScopeRepo */ + $userScopeRepo = $this->repository('Xfrocks\Api:UserScope'); + $userScopes = $this->finder('Xfrocks\Api:UserScope') + ->where('client_id', $client->client_id) + ->where('user_id', $visitor->user_id) + ->fetch(); + + if ($userScopes->count() === 0) { + return $this->noPermission(); + } + + if ($this->isPost()) { + $isRevoke = $this->filter('revoke', 'bool') === true; + $scopes = $this->filter('scopes', 'array-str'); + if (count($scopes) === 0) { + $isRevoke = true; + } + + $db = $this->app()->db(); + $db->beginTransaction(); + + try { + $userScopesChanged = false; + /** @var UserScope $userScope */ + foreach ($userScopes as $userScope) { + if ($isRevoke || !in_array($userScope->scope, $scopes, true)) { + $userScopeRepo->deleteUserScope($client->client_id, $visitor->user_id, $userScope->scope); + $userScopesChanged = true; + } + } + + if ($userScopesChanged) { + /** @var AuthCode $authCodeRepo */ + $authCodeRepo = $this->repository('Xfrocks\Api:AuthCode'); + $authCodeRepo->deleteAuthCodes($client->client_id, $visitor->user_id); + /** @var RefreshToken $refreshTokenRepo */ + $refreshTokenRepo = $this->repository('Xfrocks\Api:RefreshToken'); + $refreshTokenRepo->deleteRefreshTokens($client->client_id, $visitor->user_id); + /** @var \Xfrocks\Api\Repository\Token $tokenRepo */ + $tokenRepo = $this->repository('Xfrocks\Api:Token'); + $tokenRepo->deleteTokens($client->client_id, $visitor->user_id); + } + + if ($isRevoke) { + /** @var Subscription $subscriptionRepo */ + $subscriptionRepo = $this->repository('Xfrocks\Api:Subscription'); + $subscriptionRepo->deleteSubscriptions( + $client->client_id, + Subscription::TYPE_USER, + $visitor->user_id + ); + + $subscriptionRepo->deleteSubscriptions( + $client->client_id, + Subscription::TYPE_NOTIFICATION, + $visitor->user_id + ); + } + + $connectedAccounts = $this->finder('XF:UserConnectedAccount') + ->where('user_id', $visitor->user_id) + ->where('provider', 'api_' . $client->client_id) + ->fetch(); + /** @var UserConnectedAccount $connectedAccount */ + foreach ($connectedAccounts as $connectedAccount) { + $connectedAccount->delete(true, false); + } + + $db->commit(); + } catch (\Throwable $e) { + $db->rollback(); + + throw $e; + } + + return $this->redirect($this->buildLink('account/api')); + } + + $scopesPhrase = []; + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + + foreach ($userScopes as $userScope) { + $scopesPhrase[$userScope->scope] = $apiServer->getScopeDescription($userScope->scope); + } + + $viewParams = [ + 'client' => $client, + 'userScopes' => $userScopes, + 'scopesPhrase' => $scopesPhrase, + ]; + + $view = $this->view('Xfrocks\Api:Account\Api\UpdateScope', 'bdapi_account_api_update_scope', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + /** + * @return \XF\Mvc\Reply\View + */ + public function actionApiClientAdd() + { + /** @var User $visitor */ + $visitor = \XF::visitor(); + if (!$visitor->canAddApiClient()) { + return $this->noPermission(); + } + + $viewParams = [ + 'client' => $this->em()->create('Xfrocks\Api:Client') + ]; + + $view = $this->view('Xfrocks\Api:Account\Api\Edit', 'bdapi_account_api_client_add', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + /** + * @return \XF\Mvc\Reply\Redirect|\XF\Mvc\Reply\View + * @throws \XF\PrintableException + * @throws \XF\Mvc\Reply\Exception + */ + public function actionApiClientDelete() + { + $client = $this->assertEditableApiClient($this->filter('client_id', 'str')); + + if ($this->isPost()) { + $client->delete(); + + return $this->redirect($this->buildLink('account/api')); + } + + $viewParams = [ + 'client' => $client + ]; + + $view = $this->view('Xfrocks\Api:Account\Api\Delete', 'bdapi_account_api_client_delete', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + /** + * @return \XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + public function actionApiClientEdit() + { + $client = $this->assertEditableApiClient($this->filter('client_id', 'str')); + + $viewParams = [ + 'client' => $client + ]; + + $view = $this->view('Xfrocks\Api:Account\Api\Edit', 'bdapi_account_api_client_edit', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + /** + * @return \XF\Mvc\Reply\Redirect + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionApiClientSave() + { + $this->assertPostOnly(); + + $clientId = $this->filter('client_id', 'str'); + + if ($clientId !== '') { + $client = $this->assertEditableApiClient($clientId); + } else { + /** @var User $visitor */ + $visitor = \XF::visitor(); + if (!$visitor->canAddApiClient()) { + return $this->noPermission(); + } + + /** @var Client $client */ + $client = $this->em()->create('Xfrocks\Api:Client'); + } + + $client->bulkSet($this->filter([ + 'name' => 'str', + 'description' => 'str', + 'redirect_uri' => 'str' + ])); + + $options = $this->filter('options', 'array'); + $client->setClientOptions($this->filterArray( + $options, + [ + 'whitelisted_domains' => 'str' + ] + )); + + $client->save(); + + return $this->redirect($this->buildLink('account/api')); + } + + /** + * @return \XF\Mvc\Reply\Redirect + * @throws \XF\PrintableException + */ + public function actionApiLogout() + { + /** @var Login $loginPlugin */ + $loginPlugin = $this->plugin('Xfrocks\Api:Login'); + return $loginPlugin->logout(); + } + + /** + * @return \XF\Mvc\Reply\Redirect|\XF\Mvc\Reply\View + * @throws \League\OAuth2\Server\Exception\InvalidGrantException + * @throws \League\OAuth2\Server\Exception\UnsupportedResponseTypeException + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionAuthorize() + { + /** @var Server $apiServer */ + $apiServer = $this->app->container('api.server'); + + $clientId = $this->filter('client_id', 'str'); + $clientIsAuto = false; + if ($clientId === '') { + /** @var Client|null $visitorClient */ + $visitorClient = $this->getApiClientRepo()->findUserClients(\XF::visitor()->user_id) + ->order(Finder::ORDER_RANDOM) + ->fetchOne(); + if ($visitorClient !== null) { + $clientIsAuto = true; + $apiServer->setRequestQuery('client_id', $visitorClient->client_id); + $apiServer->setRequestQuery('redirect_uri', $visitorClient->redirect_uri); + $apiServer->setRequestQuery('response_type', 'token'); + $apiServer->setRequestQuery('scope', Server::SCOPE_READ); + $apiServer->setRequestQuery('state', Random::getRandomString(32)); + } + } + + $linkParams = $authorizeParams = $apiServer->grantAuthCodeCheckParams($this); + + /** @var Client $client */ + $client = $linkParams['client']; + unset($linkParams['client']); + $linkParams['client_id'] = $client->client_id; + + $scopeIds = $linkParams['scopes']; + unset($linkParams['scopes']); + + $needAuthScopes = []; + $requestedScopes = []; + $userScopes = $this->finder('Xfrocks\Api:UserScope') + ->where('client_id', $client->client_id) + ->where('user_id', \XF::visitor()->user_id) + ->keyedBy('scope') + ->fetch(); + foreach ($scopeIds as $scopeId) { + $requestedScopes[$scopeId] = $apiServer->getScopeDescription($scopeId); + + // TODO: auto authorize scopes + + if (!isset($userScopes[$scopeId])) { + $needAuthScopes[$scopeId] = $requestedScopes[$scopeId]; + } + } + + $bypassConfirmation = false; + if (count($requestedScopes) > 0 && count($needAuthScopes) === 0) { + $bypassConfirmation = true; + } + if ($clientIsAuto) { + $bypassConfirmation = false; + } + + if ($this->isPost() || $bypassConfirmation) { + $userScopeKeys = $userScopes->keys(); + if ($this->isPost()) { + $authorizeParams['scopes'] = $this->filter('scopes', 'array-str'); + } + $authorizeParams['scopes'] = array_merge($authorizeParams['scopes'], $userScopeKeys); + $authorizeParams['scopes'] = array_unique($authorizeParams['scopes']); + + return $apiServer->grantAuthCodeNewAuthRequest($this, $authorizeParams); + } + + $viewParams = [ + 'client' => $client, + 'needAuthScopes' => $needAuthScopes, + + 'clientIsAuto' => $clientIsAuto, + 'linkParams' => $linkParams, + ]; + + $view = $this->view('Xfrocks\Api:Account\Authorize', 'bdapi_account_authorize', $viewParams); + return $this->addAccountWrapperParams($view, 'api'); + } + + /** + * @param string $clientId + * @return Client + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertEditableApiClient($clientId) + { + /** @var Client $client */ + $client = $this->assertRecordExists('Xfrocks\Api:Client', $clientId); + + if (!$client->canEdit()) { + throw $this->exception($this->notFound(\XF::phrase('bdapi_requested_client_not_found'))); + } + + return $client; + } + + /** + * @return \Xfrocks\Api\Repository\Client + */ + protected function getApiClientRepo() + { + /** @var \Xfrocks\Api\Repository\Client $clientRepo */ + $clientRepo = $this->repository('Xfrocks\Api:Client'); + return $clientRepo; + } +} diff --git a/XF/Pub/Controller/Misc.php b/XF/Pub/Controller/Misc.php new file mode 100644 index 00000000..a506d1df --- /dev/null +++ b/XF/Pub/Controller/Misc.php @@ -0,0 +1,200 @@ +filter([ + 'html' => 'str', + 'required_externals' => 'str', + 'timestamp' => 'int', + ]); + + $html = Crypt::decryptTypeOne($input['html'], $input['timestamp']); + + $requiredExternals = []; + if (strlen($input['required_externals']) > 0) { + $requiredExternals = \GuzzleHttp\json_decode( + Crypt::decryptTypeOne($input['required_externals'], $input['timestamp']), + true + ); + } + + $viewParams = compact('html', 'requiredExternals'); + return $this->view('Xfrocks\Api:Misc\Api\Chr', 'bdapi_misc_chr', $viewParams); + } + + /** + * @return \XF\Mvc\Reply\View + * @throws \XF\Mvc\Reply\Exception + */ + public function actionApiData() + { + $callback = $this->filter('callback', 'str'); + $clientId = $this->filter('client_id', 'str'); + $cmd = $this->filter('cmd', 'str'); + $scope = $this->filter('scope', 'str'); + + $data = [$cmd => 0]; + + /** @var Client $client */ + $client = $this->assertRecordExists('Xfrocks\Api:Client', $clientId); + $visitor = \XF::visitor(); + + switch ($cmd) { + case 'authorized': + if ($scope === '') { + // no scope requested, check for scope `read` + $requestedScopes = [Server::SCOPE_READ]; + } else { + $requestedScopes = explode(Listener::$scopeDelimiter, $scope); + if (!is_array($requestedScopes)) { + $requestedScopes = []; + } + } + $requestedScopesAccepted = []; + + // TODO: check for auto authorize + + $userScopes = $this->finder('Xfrocks\Api:UserScope') + ->where('client_id', $client->client_id) + ->where('user_id', $visitor->user_id) + ->keyedBy('scope') + ->fetch(); + + foreach ($requestedScopes as $requestedScope) { + if (isset($userScopes[$requestedScope])) { + $requestedScopesAccepted[] = $requestedScope; + } + } + + if (count($requestedScopes) === count($requestedScopesAccepted)) { + $data[$cmd] = 1; + } + + if ($data[$cmd] > 0) { + if ($scope !== '') { + $data += $this->prepareApiDataForVisitor($visitor, $requestedScopesAccepted); + } else { + // just checking for connection status, return user_id only + $data['user_id'] = $visitor->user_id; + } + } + + // switch ($cmd) + break; + } + + $this->signApiData($client, $data); + + $viewParams = [ + 'callback' => $callback, + 'client_id' => $clientId, + 'cmd' => $cmd, + 'data' => $data, + ]; + + $this->setResponseType('raw'); + + return $this->view('Xfrocks\Api:Misc\ApiData', '', $viewParams); + } + + /** + * @return \XF\Mvc\Reply\Redirect + * @throws \XF\Mvc\Reply\Exception + * @throws \XF\PrintableException + */ + public function actionApiLogin() + { + /** @var Login $loginPlugin */ + $loginPlugin = $this->plugin('Xfrocks\Api:Login'); + return $loginPlugin->login('misc/api-login'); + } + + /** + * @param mixed $action + * @param ParameterBag $params + * @return void + */ + public function checkCsrfIfNeeded($action, ParameterBag $params) + { + if ($action === 'ApiData') { + return; + } + + parent::checkCsrfIfNeeded($action, $params); + } + + /** + * @param \XF\Entity\User $visitor + * @param string[] $scopes + * @return array + */ + protected function prepareApiDataForVisitor($visitor, array $scopes) + { + $data = [ + 'user_id' => $visitor->user_id, + 'username' => $visitor->username, + 'user_unread_notification_count' => $visitor->alerts_unread + ]; + + if (in_array(Server::SCOPE_PARTICIPATE_IN_CONVERSATIONS, $scopes, true)) { + $data['user_unread_conversation_count'] = $visitor->conversations_unread; + } + + return $data; + } + + /** + * @param Client $client + * @param array $data + * @return void + */ + protected function signApiData($client, array &$data) + { + $str = ''; + + $keys = array_keys($data); + asort($keys); + foreach ($keys as $key) { + if ($key === 'signature') { + // do not include existing signature when signing + // it's safe to run this method more than once with the same $data + continue; + } + + if (is_array($data[$key])) { + // do not support array in signing for now + unset($data[$key]); + continue; + } + + if (is_bool($data[$key])) { + // strval(true) = 1 while strval(false) = 0 + // so we will normalize bool to int before the strval + $data[$key] = ($data[$key] ? 1 : 0); + } + + $str .= sprintf('%s=%s&', $key, $data[$key]); + } + + if (\XF::$debugMode && !headers_sent()) { + header('X-Api-Signature-String-Without-Secret: ' . $str); + } + $str .= $client->client_secret; + + $data['signature'] = md5($str); + } +} diff --git a/XF/Repository/Node.php b/XF/Repository/Node.php new file mode 100644 index 00000000..d5e22564 --- /dev/null +++ b/XF/Repository/Node.php @@ -0,0 +1,45 @@ +filesPrepared) { + return; + } + + parent::prepareFilesToHash(); + + $keys = []; + foreach ($this->filesToHash as $key => $path) { + if (preg_match('#_build/upload/api/[^/]+$#', $path) !== 1) { + continue; + } + + $keys[] = $key; + } + + foreach ($keys as $key) { + unset($this->filesToHash[$key]); + } + } +} diff --git a/XF/Service/Conversation/Notifier.php b/XF/Service/Conversation/Notifier.php new file mode 100644 index 00000000..2c83d5d5 --- /dev/null +++ b/XF/Service/Conversation/Notifier.php @@ -0,0 +1,44 @@ +conversation->FirstMessage; + $sender = $sender !== null ? $sender : $message->User; + + foreach ($notifyUsers as $user) { + if ($sender->user_id == $user->user_id) { + continue; + } + + /** @var Subscription $subscriptionRepo */ + $subscriptionRepo = $this->repository('Xfrocks\Api:Subscription'); + $subscriptionRepo->pingConversationMessage( + $actionType, + $message, + $user, + $sender + ); + } + + return $sent; + } +} diff --git a/XF/Service/User/DeleteCleanUp.php b/XF/Service/User/DeleteCleanUp.php new file mode 100644 index 00000000..3ba4bac5 --- /dev/null +++ b/XF/Service/User/DeleteCleanUp.php @@ -0,0 +1,42 @@ +deletes['xf_bdapi_auth_code'] = 'user_id = ?'; + $this->deletes['xf_bdapi_refresh_token'] = 'user_id = ?'; + $this->deletes['xf_bdapi_token'] = 'user_id = ?'; + $this->deletes['xf_bdapi_user_scope'] = 'user_id = ?'; + + $this->steps[] = 'stepDeleteApiSubscriptions'; + } + + /** + * @return void + */ + public function stepDeleteApiSubscriptions() + { + $app = $this->app; + $options = $app->options(); + + if ($options->bdApi_subscriptionUser + || $options->bdApi_subscriptionUserNotification + ) { + /** @var Subscription $subRepo */ + $subRepo = $this->repository('Xfrocks\Api:Subscription'); + $subRepo->deleteSubscriptionsForTopic(Subscription::TYPE_USER, $this->userId); + } + } +} diff --git a/XF/Transform/AbstractNode.php b/XF/Transform/AbstractNode.php new file mode 100644 index 00000000..4d802fdd --- /dev/null +++ b/XF/Transform/AbstractNode.php @@ -0,0 +1,95 @@ + $this->getNameSingular() . '_id', + 'title' => $this->getNameSingular() . '_title', + 'description' => $this->getNameSingular() . '_description' + ]; + + $this->nodeRepo()->apiTransformGetMappings($context, $mappings); + + return $mappings; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\AbstractNode $node */ + $node = $context->getSource(); + + $links = [ + self::LINK_PERMALINK => $this->buildApiLink($this->getRoutePrefix(), $node), + self::LINK_DETAIL => $this->buildApiLink($this->getRoutePrefix(), $node), + ]; + + $nodeNode = $node->Node; + if ($nodeNode !== null && $nodeNode->rgt - $nodeNode->lft > 1) { + $linkParams = ['parent_node_id' => $node->node_id]; + $links += [ + self::LINK_SUB_CATEGORIES => $this->buildApiLink('categories', null, $linkParams), + self::LINK_SUB_FORUMS => $this->buildApiLink('forums', null, $linkParams) + ]; + } + + $this->nodeRepo()->apiTransformCollectLinks($context, $links); + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + $perms = []; + + $node = $context->getSource(); + $canView = [$node, 'canView']; + if (is_callable($canView)) { + $perms[self::PERM_VIEW] = call_user_func($canView); + } + + $visitor = \XF::visitor(); + $perms[self::PERM_EDIT] = $visitor->hasAdminPermission('node'); + $perms[self::PERM_DELETE] = $visitor->hasAdminPermission('node'); + + $this->nodeRepo()->apiTransformCollectPermissions($context, $perms); + + return $perms; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + $value = $this->nodeRepo()->apiTransformCalculateDynamicValue($context, $key); + return $value !== null ? $value : parent::calculateDynamicValue($context, $key); + } + + /** + * @return Node + */ + protected function nodeRepo() + { + /** @var Node $nodeRepo */ + $nodeRepo = $this->app->repository('XF:Node'); + return $nodeRepo; + } + + /** + * @return string + */ + abstract protected function getNameSingular(); + + /** + * @return string + */ + abstract protected function getRoutePrefix(); +} diff --git a/XF/Transform/Attachment.php b/XF/Transform/Attachment.php new file mode 100644 index 00000000..8315a348 --- /dev/null +++ b/XF/Transform/Attachment.php @@ -0,0 +1,123 @@ +getSource(); + $data = $attachment->Data; + if ($data !== null && $data->height > 0 && $data->width > 0) { + return $key == self::KEY_HEIGHT ? $data->height : $data->width; + } + + return null; + case self::KEY_IS_INSERTED: + $parentSource = $context->getParentSource(); + $isAttachmentEmbedded = [$parentSource, 'isAttachmentEmbedded']; + if (is_callable($isAttachmentEmbedded)) { + /** @var \XF\Entity\Attachment $attachment */ + $attachment = $context->getSource(); + return call_user_func($isAttachmentEmbedded, $attachment->attachment_id); + } + + return null; + } + + /** @var AttachmentParent|null $parentHandler */ + $parentHandler = $context->getParentHandler(); + if ($parentHandler !== null) { + return $parentHandler->attachmentCalculateDynamicValue($context, $key); + } + + return parent::calculateDynamicValue($context, $key); + } + + public function canView(TransformContext $context) + { + return true; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\Attachment $attachment */ + $attachment = $context->getSource(); + + $links = [ + self::LINK_DATA => $this->buildApiLink('attachments', $attachment, ['hash' => $attachment->temp_hash]), + self::LINK_PERMALINK => $this->buildPublicLink('attachments', $attachment), + ]; + + $thumbnailUrl = $attachment->thumbnail_url; + if ($thumbnailUrl !== '') { + $links[self::LINK_THUMBNAIL] = $thumbnailUrl; + } + + /** @var AttachmentParent|null $parentHandler */ + $parentHandler = $context->getParentHandler(); + if ($parentHandler !== null) { + $parentHandler->attachmentCollectLinks($context, $links); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\Attachment $attachment */ + $attachment = $context->getSource(); + + $permissions = [ + self::PERM_DELETE => false, + self::PERM_VIEW => $attachment->canView(), + ]; + + /** @var AttachmentParent|null $parentHandler */ + $parentHandler = $context->getParentHandler(); + if ($parentHandler !== null) { + $parentHandler->attachmentCollectPermissions($context, $permissions); + } + + return $permissions; + } + + public function getMappings(TransformContext $context) + { + $mappings = [ + 'attachment_id' => self::KEY_ID, + 'filename' => self::KEY_FILENAME, + 'view_count' => self::KEY_DOWNLOAD_COUNT, + + self::KEY_HEIGHT, + self::KEY_IS_INSERTED, + self::KEY_WIDTH, + ]; + + /** @var AttachmentParent|null $parentHandler */ + $parentHandler = $context->getParentHandler(); + if ($parentHandler !== null) { + $parentHandler->attachmentGetMappings($context, $mappings); + } + + return $mappings; + } +} diff --git a/XF/Transform/Category.php b/XF/Transform/Category.php new file mode 100644 index 00000000..32340175 --- /dev/null +++ b/XF/Transform/Category.php @@ -0,0 +1,16 @@ + self::KEY_CREATE_DATE, + 'conversation_id' => self::KEY_ID, + 'reply_count' => self::KEY_MESSAGE_COUNT, + 'title' => self::KEY_TITLE, + 'user_id' => self::KEY_CREATOR_USER_ID, + 'username' => self::KEY_CREATOR_USERNAME, + 'last_message_date' => self::KEY_UPDATE_DATE, + + self::DYNAMIC_KEY_IS_DELETED, + self::DYNAMIC_KEY_IS_IGNORED, + self::DYNAMIC_KEY_IS_OPEN, + self::DYNAMIC_KEY_FIRST_MESSAGE, + self::DYNAMIC_KEY_HAS_NEW_MESSAGE, + self::DYNAMIC_KEY_LAST_MESSAGE, + self::DYNAMIC_KEY_RECIPIENTS + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\ConversationMaster $conversation */ + $conversation = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_IS_DELETED: + $userId = \XF::visitor()->user_id; + + if (!isset($conversation->Recipients[$userId])) { + return true; + } + $recipient = $conversation->Recipients[$userId]; + + return in_array($recipient->recipient_state, ['deleted', 'deleted_ignored'], true); + case self::DYNAMIC_KEY_IS_IGNORED: + return \XF::visitor()->isIgnoring($conversation->user_id); + case self::DYNAMIC_KEY_IS_OPEN: + $userId = \XF::visitor()->user_id; + + if (!isset($conversation->Recipients[$userId])) { + return false; + } + $recipient = $conversation->Recipients[$userId]; + + return $recipient->recipient_state === 'active'; + case self::DYNAMIC_KEY_FIRST_MESSAGE: + $firstMessage = $conversation->FirstMessage; + if ($firstMessage === null) { + return null; + } + + return $this->transformer->transformEntity($context, $key, $firstMessage); + case self::DYNAMIC_KEY_HAS_NEW_MESSAGE: + $userId = \XF::visitor()->user_id; + + if (!isset($conversation->Recipients[$userId])) { + return false; + } + $recipient = $conversation->Recipients[$userId]; + + return $recipient->last_read_date < $conversation->last_message_date; + case self::DYNAMIC_KEY_LAST_MESSAGE: + if (!$context->selectorShouldIncludeField($key)) { + return null; + } + + $lastMessage = $conversation->LastMessage; + if ($lastMessage === null) { + return null; + } + + return $this->transformer->transformEntity($context, $key, $lastMessage); + case self::DYNAMIC_KEY_RECIPIENTS: + return $this->transformer->transformEntityRelation($context, $key, $conversation, 'Recipients'); + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\ConversationMaster $conversation */ + $conversation = $context->getSource(); + $links = [ + self::LINK_PERMALINK => $this->buildPublicLink('conversations', $conversation), + self::LINK_DETAIL => $this->buildApiLink('conversations', $conversation), + self::LINK_MESSAGES => $this->buildApiLink( + 'conversation-messages', + null, + ['conversation_id' => $conversation->conversation_id] + ) + ]; + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\ConversationMaster $conversation */ + $conversation = $context->getSource(); + $perms = [ + self::PERM_REPLY => $conversation->canReply(), + self::PERM_DELETE => true, + self::PERM_UPLOAD_ATTACHMENT => $conversation->canUploadAndManageAttachments() + ]; + + return $perms; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_FIRST_MESSAGE)) { + $this->callOnTransformEntitiesForRelation( + $context, + $entities, + self::DYNAMIC_KEY_FIRST_MESSAGE, + 'FirstMessage' + ); + } + + if ($context->selectorShouldIncludeField(self::DYNAMIC_KEY_LAST_MESSAGE)) { + $this->callOnTransformEntitiesForRelation( + $context, + $entities, + self::DYNAMIC_KEY_LAST_MESSAGE, + 'LastMessage' + ); + } + + $convoIds = array_keys($entities); + if (count($convoIds) > 0) { + $convoUsers = $this->app->em()->getFinder('XF:ConversationUser') + ->where('conversation_id', $convoIds) + ->fetch(); + $convoUsersByConvoId = []; + /** @var \XF\Entity\ConversationUser $convoUser */ + foreach ($convoUsers as $convoUser) { + $convoUsersByConvoId[$convoUser->conversation_id][$convoUser->owner_user_id] = $convoUser; + } + /** @var \XF\Entity\ConversationMaster $convo */ + foreach ($entities as $convo) { + $thisConvoUsers = $convoUsersByConvoId[$convo->conversation_id]; + $convo->hydrateRelation('Users', new \XF\Mvc\Entity\ArrayCollection($thisConvoUsers)); + } + } + + return parent::onTransformEntities($context, $entities); + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_FIRST_MESSAGE)) { + $this->callOnTransformFinderForRelation( + $context, + $finder, + self::DYNAMIC_KEY_FIRST_MESSAGE, + 'FirstMessage' + ); + } + + if ($context->selectorShouldIncludeField(self::DYNAMIC_KEY_LAST_MESSAGE)) { + $this->callOnTransformFinderForRelation( + $context, + $finder, + self::DYNAMIC_KEY_LAST_MESSAGE, + 'LastMessage' + ); + } + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/XF/Transform/ConversationMessage.php b/XF/Transform/ConversationMessage.php new file mode 100644 index 00000000..93a7b2de --- /dev/null +++ b/XF/Transform/ConversationMessage.php @@ -0,0 +1,207 @@ +getParentSourceValue('message_id'); + } + + return null; + } + + public function attachmentCollectPermissions(TransformContext $context, array &$permissions) + { + /** @var \XF\Entity\ConversationMessage $message */ + $message = $context->getParentSource(); + + $canDelete = false; + $conversation = $message->Conversation; + + if ($conversation !== null && $conversation->canUploadAndManageAttachments()) { + $canDelete = $this->checkAttachmentCanManage(self::CONTENT_TYPE_CONVO_MESSAGE, $message); + } + + $permissions[self::PERM_DELETE] = $canDelete; + } + + public function attachmentCollectLinks(TransformContext $context, array &$links) + { + /** @var \XF\Entity\ConversationMessage $message */ + $message = $context->getParentSource(); + + $links[self::ATTACHMENT__LINK_MESSAGE] = $this->buildApiLink('conversation-messages', $message); + } + + public function attachmentGetMappings(TransformContext $context, array &$mappings) + { + $mappings[] = self::ATTACHMENT__DYNAMIC_KEY_ID; + } + + public function getMappings(TransformContext $context) + { + return [ + 'message_id' => self::KEY_ID, + 'conversation_id' => self::KEY_CONVERSATION_ID, + 'user_id' => self::KEY_CREATOR_USER_ID, + 'username' => self::KEY_CREATOR_USERNAME, + 'message_date' => self::KEY_CREATE_DATE, + 'attach_count' => self::KEY_ATTACH_COUNT, + + self::DYNAMIC_KEY_ATTACHMENTS, + self::DYNAMIC_KEY_BODY, + self::DYNAMIC_KEY_BODY_HTML, + self::DYNAMIC_KEY_BODY_PLAIN, + self::DYNAMIC_KEY_SIGNATURE, + self::DYNAMIC_KEY_SIGNATURE_HTML, + self::DYNAMIC_KEY_SIGNATURE_PLAIN, + self::DYNAMIC_KEY_USER_IS_IGNORED + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\ConversationMessage $message */ + $message = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_ATTACHMENTS: + if ($message->attach_count < 1) { + return null; + } + + return $this->transformer->transformEntityRelation($context, $key, $message, 'Attachments'); + case self::DYNAMIC_KEY_BODY: + return $message->message; + case self::DYNAMIC_KEY_BODY_HTML: + return $this->renderBbCodeHtml($key, $message->message, $message); + case self::DYNAMIC_KEY_BODY_PLAIN: + return $this->renderBbCodePlainText($message->message); + case self::DYNAMIC_KEY_IS_LIKED: + return $message->isReactedTo(); + case self::DYNAMIC_KEY_SIGNATURE: + case self::DYNAMIC_KEY_SIGNATURE_HTML: + case self::DYNAMIC_KEY_SIGNATURE_PLAIN: + if ($message->user_id < 1) { + return null; + } + + $user = $message->User; + if ($user === null) { + return null; + } + + $profile = $user->Profile; + if ($profile === null) { + return null; + } + + switch ($key) { + case self::DYNAMIC_KEY_SIGNATURE: + return $profile->signature; + case self::DYNAMIC_KEY_SIGNATURE_HTML: + return $this->renderBbCodeHtml($key, $profile->signature, $profile); + case self::DYNAMIC_KEY_SIGNATURE_PLAIN: + return $this->renderBbCodePlainText($profile->signature); + } + break; + case self::DYNAMIC_KEY_USER_IS_IGNORED: + return $message->isIgnored(); + } + + return parent::calculateDynamicValue($context, $key); + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\ConversationMessage $message */ + $message = $context->getSource(); + $user = $message->User; + + $links = [ + self::LINK_DETAIL => $this->buildApiLink('conversation-messages', $message), + self::LINK_CONVERSATION => $this->buildApiLink('conversations', $message->Conversation), + self::LINK_REPORT => $this->buildApiLink('conversation-messages/report', $message) + ]; + + if ($user !== null) { + $links[self::LINK_CREATOR] = $this->buildApiLink('users', $user); + $links[self::LINK_CREATOR_AVATAR] = $user->getAvatarUrl('l'); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\ConversationMessage $message */ + $message = $context->getSource(); + $conversation = $message->Conversation; + + return [ + self::PERM_VIEW => $message->canView(), + self::PERM_EDIT => $message->canEdit(), + + // save value for version of xenforo 1.x + self::PERM_DELETE => true, + + self::PERM_REPLY => $conversation !== null ? $conversation->canReply() : null, + self::PERM_UPLOAD_ATTACHMENT => $conversation !== null ? $conversation->canUploadAndManageAttachments() : null, + + self::PERM_LIKE => $message->canReact(), + ]; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + $needAttachments = false; + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_ATTACHMENTS)) { + $needAttachments = true; + } + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_BODY_HTML)) { + $needAttachments = true; + } + + if ($needAttachments) { + $this->enqueueEntitiesToAddAttachmentsTo($entities, self::CONTENT_TYPE_CONVO_MESSAGE); + } + + return parent::onTransformEntities($context, $entities); + } +} diff --git a/XF/Transform/ConversationRecipient.php b/XF/Transform/ConversationRecipient.php new file mode 100644 index 00000000..b05ab61b --- /dev/null +++ b/XF/Transform/ConversationRecipient.php @@ -0,0 +1,68 @@ + self::KEY_USER_ID, + + self::DYNAMIC_KEY_USERNAME, + self::DYNAMIC_KEY_AVATAR, + self::DYNAMIC_KEY_AVATAR_BIG, + self::DYNAMIC_KEY_AVATAR_SMALL + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\ConversationRecipient $recipient */ + $recipient = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_USERNAME: + $user = $recipient->User; + if ($user === null) { + return \XF::phrase('deleted_member'); + } + + return $user->username; + case self::DYNAMIC_KEY_AVATAR: + return $this->collectUserAvatarUrl($recipient, 'l'); + case self::DYNAMIC_KEY_AVATAR_BIG: + return $this->collectUserAvatarUrl($recipient, 'o'); + case self::DYNAMIC_KEY_AVATAR_SMALL: + return $this->collectUserAvatarUrl($recipient, 's'); + } + + return null; + } + + /** + * @param \XF\Entity\ConversationRecipient $recipient + * @param string $sizeCode + * @return mixed|string|null + */ + protected function collectUserAvatarUrl(\XF\Entity\ConversationRecipient $recipient, $sizeCode) + { + $user = $recipient->User; + return $user !== null ? $user->getAvatarUrl($sizeCode) : null; + } +} diff --git a/XF/Transform/ConversationUser.php b/XF/Transform/ConversationUser.php new file mode 100644 index 00000000..4f3cb990 --- /dev/null +++ b/XF/Transform/ConversationUser.php @@ -0,0 +1,35 @@ +callOnTransformEntitiesForRelation($context, $entities, null, 'Master'); + + return parent::onTransformEntities($context, $entities); + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $this->callOnTransformFinderForRelation($context, $finder, null, 'Master'); + + return parent::onTransformFinder($context, $finder); + } + + public function onTransformed(TransformContext $context, array &$data) + { + parent::onTransformed($context, $data); + + $data += $this->transformer->transformEntityRelation($context, null, $context->getSource(), 'Master'); + } +} diff --git a/XF/Transform/Forum.php b/XF/Transform/Forum.php new file mode 100644 index 00000000..bf0d86c3 --- /dev/null +++ b/XF/Transform/Forum.php @@ -0,0 +1,107 @@ + self::KEY_THREAD_COUNT, + 'message_count' => self::KEY_POST_COUNT, + 'default_prefix_id' => self::KEY_DEFAULT_THREAD_PREFIX_ID, + 'require_prefix' => self::KEY_PREFIX_IS_REQUIRED, + + self::DYNAMIC_KEY_IS_FOLLOW, + self::DYNAMIC_KEY_PREFIXES + ]; + + return $mappings; + } + + public function collectLinks(TransformContext $context) + { + /** @var array $links */ + $links = parent::collectLinks($context); + /** @var \XF\Entity\Forum $forum */ + $forum = $context->getSource(); + + $links += [ + self::LINK_FOLLOWERS => $this->buildApiLink('forums/followers', $forum), + self::LINK_THREADS => $this->buildApiLink('threads', null, ['forum_id' => $forum->node_id]) + ]; + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\Forum $forum */ + $forum = $context->getSource(); + /** @var array $perms */ + $perms = parent::collectPermissions($context); + + $perms += [ + self::PERM_FOLLOW => $forum->canWatch(), + self::PERM_CREATE_THREAD => $forum->canCreateThread(), + self::PERM_UPLOAD_ATTACHMENT => $forum->canUploadAndManageAttachments() + ]; + + return $perms; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\Forum $forum */ + $forum = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_IS_FOLLOW: + $userId = \XF::visitor()->user_id; + if ($userId < 1) { + return false; + } + + return isset($forum->Watch[$userId]); + case self::DYNAMIC_KEY_PREFIXES: + if (count($forum->prefix_cache) === 0) { + return null; + } + + $finder = $forum->finder('XF:ThreadPrefix') + ->where('prefix_id', $forum->prefix_cache) + ->order('materialized_order'); + + return $this->transformer->transformFinder($context, $key, $finder); + } + + return parent::calculateDynamicValue($context, $key); + } + + protected function getNameSingular() + { + return 'forum'; + } + + protected function getRoutePrefix() + { + return 'forums'; + } +} diff --git a/XF/Transform/LinkForum.php b/XF/Transform/LinkForum.php new file mode 100644 index 00000000..a78b0b27 --- /dev/null +++ b/XF/Transform/LinkForum.php @@ -0,0 +1,33 @@ +getSource(); + if ($linkForum->link_url !== '') { + $links['target'] = $linkForum->link_url; + } else { + $links['target'] = $this->buildPublicLink('link-forums', $linkForum); + } + + return $links; + } + + protected function getNameSingular() + { + return 'link'; + } + + protected function getRoutePrefix() + { + return 'link-forums'; + } +} diff --git a/XF/Transform/Page.php b/XF/Transform/Page.php new file mode 100644 index 00000000..52f0f186 --- /dev/null +++ b/XF/Transform/Page.php @@ -0,0 +1,60 @@ +getSource(); + + if ($key === self::DYNAMIC_KEY_PAGE_HTML) { + return $this->app->templater()->renderTemplate('public:' . $page->getTemplateName(), [ + 'page' => $page + ]); + } + + return parent::calculateDynamicValue($context, $key); + } + + public function collectLinks(TransformContext $context) + { + $links = parent::collectLinks($context); + + /** @var \XF\Entity\Page $page */ + $page = $context->getSource(); + + $links['sub-pages'] = $this->buildApiLink('pages', null, [ + 'parent_page_id' => $page->node_id + ]); + + return $links; + } + + protected function getNameSingular() + { + return 'page'; + } + + protected function getRoutePrefix() + { + return 'pages'; + } +} diff --git a/XF/Transform/Poll.php b/XF/Transform/Poll.php new file mode 100644 index 00000000..94c14996 --- /dev/null +++ b/XF/Transform/Poll.php @@ -0,0 +1,54 @@ +getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_IS_OPEN: + return !$poll->isClosed(); + case self::DYNAMIC_KEY_IS_VOTED: + return $poll->hasVoted(); + case self::DYNAMIC_KEY_RESPONSES: + return $this->transformer->transformEntityRelation($context, $key, $poll, 'Responses'); + } + + return null; + } + + public function getMappings(TransformContext $context) + { + return [ + 'poll_id' => self::KEY_ID, + 'question' => self::KEY_QUESTION, + 'voter_count' => self::KEY_VOTE_COUNT, + 'max_votes' => self::KEY_MAX_VOTES, + + self::DYNAMIC_KEY_IS_OPEN, + self::DYNAMIC_KEY_IS_VOTED, + self::DYNAMIC_KEY_RESPONSES + ]; + } +} diff --git a/XF/Transform/PollResponse.php b/XF/Transform/PollResponse.php new file mode 100644 index 00000000..c7e6b37a --- /dev/null +++ b/XF/Transform/PollResponse.php @@ -0,0 +1,46 @@ +getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_IS_VOTED: + /** @var \XF\Entity\Poll|null $poll */ + $poll = \XF::em()->find('XF:Poll', $response->poll_id); + return $poll !== null ? $poll->hasVoted($response->poll_response_id) : null; + } + + return null; + } + + public function getMappings(TransformContext $context) + { + return [ + 'poll_response_id' => self::KEY_ID, + 'response' => self::KEY_ANSWER, + 'response_vote_count' => self::KEY_VOTE_COUNT, + + self::DYNAMIC_KEY_IS_VOTED + ]; + } +} diff --git a/XF/Transform/Post.php b/XF/Transform/Post.php new file mode 100644 index 00000000..75bb164d --- /dev/null +++ b/XF/Transform/Post.php @@ -0,0 +1,254 @@ +getParentSourceValue('post_id'); + } + + return null; + } + + public function attachmentCollectLinks(TransformContext $context, array &$links) + { + $post = $context->getParentSource(); + $links[self::ATTACHMENT__LINK_POST] = $this->buildApiLink('posts', $post); + } + + public function attachmentCollectPermissions(TransformContext $context, array &$permissions) + { + /** @var \XF\Entity\Post $post */ + $post = $context->getParentSource(); + $canDelete = false; + + $thread = $post->Thread; + $forum = $thread !== null ? $thread->Forum : null; + if ($forum !== null && $forum->canUploadAndManageAttachments()) { + $canDelete = $this->checkAttachmentCanManage(self::CONTENT_TYPE_POST, $post); + } + + $permissions[self::PERM_DELETE] = $canDelete; + } + + public function attachmentGetMappings(TransformContext $context, array &$mappings) + { + $mappings[] = self::ATTACHMENT__DYNAMIC_KEY_ID; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\Post $post */ + $post = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_ATTACHMENTS: + if ($post->attach_count < 1) { + return null; + } + + return $this->transformer->transformEntityRelation($context, $key, $post, 'Attachments'); + case self::DYNAMIC_KEY_BODY_HTML: + return $this->renderBbCodeHtml($key, $post->message, $post); + case self::DYNAMIC_KEY_BODY_PLAIN: + return $this->renderBbCodePlainText($post->message); + case self::DYNAMIC_KEY_IS_DELETED: + return $post->message_state === 'deleted'; + case self::DYNAMIC_KEY_IS_FIRST_POST: + return $post->isFirstPost(); + case self::DYNAMIC_KEY_IS_IGNORED: + if (\XF::visitor()->user_id === 0) { + return false; + } + + return $post->isIgnored(); + case self::DYNAMIC_KEY_IS_LIKED: + return $post->isReactedTo(); + case self::DYNAMIC_KEY_IS_PUBLISHED: + return $post->message_state === 'visible'; + case self::DYNAMIC_KEY_SIGNATURE: + case self::DYNAMIC_KEY_SIGNATURE_HTML: + case self::DYNAMIC_KEY_SIGNATURE_PLAIN: + if ($post->user_id < 1) { + return null; + } + + $user = $post->User; + if ($user === null) { + return null; + } + + $userProfile = $user->Profile; + if ($userProfile === null) { + return null; + } + + switch ($key) { + case self::DYNAMIC_KEY_SIGNATURE: + return $userProfile->signature; + case self::DYNAMIC_KEY_SIGNATURE_HTML: + return $this->renderBbCodeHtml($key, $userProfile->signature, $user); + case self::DYNAMIC_KEY_SIGNATURE_PLAIN: + return $this->renderBbCodePlainText($userProfile->signature); + } + } + + return null; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\Post $post */ + $post = $context->getSource(); + $thread = $post->Thread; + $forum = $thread !== null ? $thread->Forum : null; + + return [ + self::PERM_DELETE => $post->canDelete(), + self::PERM_EDIT => $post->canEdit(), + self::PERM_LIKE => $post->canReact(), + self::PERM_REPLY => $thread !== null ? $thread->canReply() : null, + self::PERM_REPORT => $post->canReport(), + self::PERM_UPLOAD_ATTACHMENT => $forum !== null ? $forum->canUploadAndManageAttachments() : null, + ]; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\Post $post */ + $post = $context->getSource(); + + $links = [ + self::LINK_ATTACHMENTS => $this->buildApiLink('posts/attachments', $post), + self::LINK_DETAIL => $this->buildApiLink('posts', $post), + self::LINK_LIKES => $this->buildApiLink('posts/likes', $post), + self::LINK_PERMALINK => $this->buildPublicLink('posts', $post), + self::LINK_REPORT => $this->buildApiLink('posts/report', $post), + self::LINK_THREAD => $this->buildApiLink('threads', $post->Thread), + ]; + + if ($post->user_id > 0) { + $user = $post->User; + if ($user !== null) { + $links[self::LINK_POSTER] = $this->buildApiLink('users', $post->User); + $links[self::LINK_POSTER_AVATAR] = $user->getAvatarUrl('l'); + } + } + + return $links; + } + + public function getMappings(TransformContext $context) + { + return [ + 'attach_count' => self::KEY_ATTACHMENT_COUNT, + 'last_edit_date' => self::KEY_UPDATE_DATE, + 'message' => self::KEY_BODY, + 'post_date' => self::KEY_CREATE_DATE, + 'post_id' => self::KEY_ID, + 'reaction_score' => self::KEY_LIKE_COUNT, + 'thread_id' => self::KEY_THREAD_ID, + 'user_id' => self::KEY_POSTER_USER_ID, + 'username' => self::KEY_POSTER_USERNAME, + + self::DYNAMIC_KEY_ATTACHMENTS, + self::DYNAMIC_KEY_BODY_HTML, + self::DYNAMIC_KEY_BODY_PLAIN, + self::DYNAMIC_KEY_IS_DELETED, + self::DYNAMIC_KEY_IS_FIRST_POST, + self::DYNAMIC_KEY_IS_IGNORED, + self::DYNAMIC_KEY_IS_LIKED, + self::DYNAMIC_KEY_IS_PUBLISHED, + self::DYNAMIC_KEY_SIGNATURE, + self::DYNAMIC_KEY_SIGNATURE_HTML, + self::DYNAMIC_KEY_SIGNATURE_PLAIN, + ]; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + $needAttachments = false; + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_ATTACHMENTS)) { + $needAttachments = true; + } + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_BODY_HTML)) { + $needAttachments = true; + } + if ($needAttachments) { + $this->enqueueEntitiesToAddAttachmentsTo($entities, self::CONTENT_TYPE_POST); + } + + return $entities; + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $threadFinder = new ParentFinder($finder, 'Thread'); + $visitor = \XF::visitor(); + + $threadFinder->with('Forum.Node.Permissions|' . $visitor->permission_combination_id); + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_SIGNATURE) || + !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_SIGNATURE_HTML) || + !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_SIGNATURE_PLAIN) + ) { + $finder->with('User.Profile'); + } + + $userId = $visitor->user_id; + if ($userId > 0) { + if (!$context->selectorShouldExcludeField(self::KEY_PERMISSIONS)) { + $threadFinder->with('ReplyBans|' . $userId); + } + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_IS_LIKED)) { + $finder->with('Reactions|' . $userId); + } + } + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/XF/Transform/ProfilePost.php b/XF/Transform/ProfilePost.php new file mode 100644 index 00000000..953ac048 --- /dev/null +++ b/XF/Transform/ProfilePost.php @@ -0,0 +1,117 @@ + self::KEY_POSTER_USER_ID, + 'username' => self::KEY_POSTER_USERNAME, + 'post_date' => self::KEY_POST_CREATE_DATE, + 'comment_count' => self::KEY_POST_COMMENT_COUNT, + 'reaction_score' => self::KEY_POST_LIKE_COUNT, + + self::DYNAMIC_KEY_POST_BODY, + self::DYNAMIC_KEY_TIMELINE_USER_ID, + self::DYNAMIC_KEY_TIMELINE_USER_NAME, + self::DYNAMIC_KEY_POST_IS_PUBLISHED, + self::DYNAMIC_KEY_POST_IS_DELETED, + self::DYNAMIC_KEY_POST_IS_LIKED, + self::DYNAMIC_KEY_USER_IS_IGNORED + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\ProfilePost $profilePost */ + $profilePost = $context->getSource(); + $profileUser = $profilePost->ProfileUser; + + switch ($key) { + case self::DYNAMIC_KEY_POST_BODY: + return $this->app->templater()->func('structured_text', [$profilePost->message]); + case self::DYNAMIC_KEY_TIMELINE_USER_ID: + return $profileUser !== null ? $profileUser->user_id : null; + case self::DYNAMIC_KEY_TIMELINE_USER_NAME: + return $profileUser !== null ? $profileUser->username : null; + case self::DYNAMIC_KEY_POST_IS_PUBLISHED: + return $profilePost->isVisible(); + case self::DYNAMIC_KEY_POST_IS_DELETED: + return $profilePost->message_state === 'deleted'; + case self::DYNAMIC_KEY_POST_IS_LIKED: + return $profilePost->isReactedTo(); + case self::DYNAMIC_KEY_USER_IS_IGNORED: + return $profilePost->isIgnored(); + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\ProfilePost $profilePost */ + $profilePost = $context->getSource(); + $user = $profilePost->User; + + $links = [ + self::LINK_PERMALINK => $this->buildPublicLink('profile-posts', $profilePost), + self::LINK_DETAIL => $this->buildApiLink('profile-posts', $profilePost), + self::LINK_TIMELINE => $this->buildApiLink('users/timeline', $profilePost->ProfileUser), + self::LINK_TIMELINE_USER => $this->buildApiLink('users', $profilePost->ProfileUser), + self::LINK_LIKES => $this->buildApiLink('profile-posts/likes', $profilePost), + self::LINK_COMMENTS => $this->buildApiLink('profile-posts/comments', $profilePost), + self::LINK_REPORT => $this->buildApiLink('profile-posts/report', $profilePost), + ]; + + if ($user !== null) { + $links[self::LINK_POSTER] = $this->buildApiLink('users', $user); + $links[self::LINK_POSTER_AVATAR] = $user->getAvatarUrl('m'); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\ProfilePost $profilePost */ + $profilePost = $context->getSource(); + + $perms = [ + self::PERM_VIEW => $profilePost->canView(), + self::PERM_EDIT => $profilePost->canEdit(), + self::PERM_DELETE => $profilePost->canDelete(), + self::PERM_LIKE => $profilePost->canReact(), + self::PERM_REPORT => $profilePost->canReport(), + self::PERM_COMMENT => $profilePost->canComment() + ]; + + return $perms; + } +} diff --git a/XF/Transform/ProfilePostComment.php b/XF/Transform/ProfilePostComment.php new file mode 100644 index 00000000..9d45093d --- /dev/null +++ b/XF/Transform/ProfilePostComment.php @@ -0,0 +1,110 @@ + self::KEY_ID, + 'profile_post_id' => self::KEY_PROFILE_POST_ID, + 'user_id' => self::KEY_COMMENT_USER_ID, + 'username' => self::KEY_COMMENT_USERNAME, + 'comment_date' => self::KEY_COMMENT_CREATE_DATE, + + self::DYNAMIC_KEY_TIMELINE_USER_ID, + self::DYNAMIC_KEY_USER_IS_IGNORED, + self::DYNAMIC_KEY_COMMENT_BODY + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\ProfilePostComment $comment */ + $comment = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_TIMELINE_USER_ID: + $profilePost = $comment->ProfilePost; + if ($profilePost === null) { + break; + } + + $profileUser = $profilePost->ProfileUser; + if ($profileUser === null) { + break; + } + + return $profileUser->user_id; + case self::DYNAMIC_KEY_COMMENT_BODY: + return $this->app->templater()->func('structured_text', [$comment->message]); + case self::DYNAMIC_KEY_USER_IS_IGNORED: + return $comment->isIgnored(); + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\ProfilePostComment $comment */ + $comment = $context->getSource(); + $profilePost = $comment->ProfilePost; + $user = $comment->User; + + $links = [ + self::LINK_DETAIL => $this->buildApiLink( + 'profile-posts/comments', + $comment->ProfilePost, + ['comment_id' => $comment->profile_post_comment_id] + ), + self::LINK_PROFILE_POST => $this->buildApiLink('profile-posts', $profilePost), + self::LINK_TIMELINE => $profilePost !== null + ? $this->buildApiLink('users/timeline', $profilePost->ProfileUser) + : null, + self::LINK_TIMELINE_USER => $profilePost !== null + ? $this->buildApiLink('users', $profilePost->ProfileUser) + : null, + ]; + + if ($user !== null) { + $links[self::LINK_POSTER] = $this->buildApiLink('users', $user); + $links[self::LINK_POSTER_AVATAR] = $user->getAvatarUrl('m'); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\ProfilePostComment $comment */ + $comment = $context->getSource(); + + $perms = [ + self::PERM_VIEW => $comment->canView(), + self::PERM_DELETE => $comment->canDelete() + ]; + + return $perms; + } +} diff --git a/XF/Transform/Tag.php b/XF/Transform/Tag.php new file mode 100644 index 00000000..8b49541f --- /dev/null +++ b/XF/Transform/Tag.php @@ -0,0 +1,34 @@ + 'tag_id', + 'tag' => 'tag_text', + 'use_count' => 'tag_use_count' + ]; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\Tag $tag */ + $tag = $context->getSource(); + + return [ + self::LINK_DETAIL => $this->buildApiLink('tags', $tag), + self::LINK_PERMALINK => $this->buildPublicLink('tags', $tag) + ]; + } +} diff --git a/XF/Transform/Thread.php b/XF/Transform/Thread.php new file mode 100644 index 00000000..2b963254 --- /dev/null +++ b/XF/Transform/Thread.php @@ -0,0 +1,244 @@ +getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_FIELDS: + $customFieldSet = $thread->custom_fields; + return $this->transformer->transformCustomFieldSet($context, $customFieldSet); + case self::DYNAMIC_KEY_FIRST_POST: + $firstPost = $thread->FirstPost; + return $firstPost !== null ? $this->transformer->transformEntity($context, $key, $firstPost) : null; + case self::DYNAMIC_KEY_FORUM: + if ($context->data(self::DYNAMIC_KEY_FORUM_DATA_KEY) === true) { + if ($context->selectorShouldExcludeField(self::DYNAMIC_KEY_FORUM)) { + return null; + } + } else { + if (!$context->selectorShouldIncludeField(self::DYNAMIC_KEY_FORUM)) { + return null; + } + } + + $forum = $thread->Forum; + return $forum !== null ? $this->transformer->transformEntity($context, $key, $forum) : null; + case self::DYNAMIC_KEY_IS_DELETED: + return $thread->discussion_state === 'deleted'; + case self::DYNAMIC_KEY_IS_FOLLOWED: + $userId = \XF::visitor()->user_id; + if ($userId < 1) { + return false; + } + + return isset($thread->Watch[$userId]); + case self::DYNAMIC_KEY_IS_PUBLISHED: + return $thread->discussion_state === 'visible'; + case self::DYNAMIC_KEY_IS_STICKY: + return $thread->sticky; + case self::DYNAMIC_KEY_POLL: + if ($thread->discussion_type !== 'poll') { + return null; + } + + $poll = $thread->Poll; + if ($poll === null) { + return null; + } + + return $this->transformer->transformEntity($context, $key, $poll); + case self::DYNAMIC_KEY_PREFIXES: + if ($thread->prefix_id === 0) { + return null; + } + + /** @var \XF\Entity\ThreadPrefix|null $prefix */ + $prefix = $thread->Prefix; + if ($prefix === null) { + return null; + } + + $prefixData = $this->transformer->transformEntity($context, $key, $prefix); + if (count($prefixData) === 0) { + return null; + } + + return [$prefixData]; + case self::DYNAMIC_KEY_TAGS: + return $this->transformer->transformTags($context, $thread->tags); + case self::DYNAMIC_KEY_USER_IS_IGNORED: + return $thread->isIgnored(); + } + + return null; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\Thread $thread */ + $thread = $context->getSource(); + $forum = $thread->Forum; + + return [ + self::PERM_DELETE => $thread->canDelete(), + self::PERM_EDIT => $thread->canEdit(), + self::PERM_EDIT_TAGS => $thread->canEditTags(), + self::PERM_EDIT_TITLE => $thread->canEdit(), + self::PERM_FOLLOW => $thread->canWatch(), + self::PERM_POST => $thread->canReply(), + self::PERM_UPLOAD_ATTACHMENT => $forum !== null ? $forum->canUploadAndManageAttachments() : null, + ]; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\Thread $thread */ + $thread = $context->getSource(); + + $links = [ + self::LINK_DETAIL => $this->buildApiLink('threads', $thread), + self::LINK_FIRST_POST => $this->buildApiLink('posts', $thread->FirstPost), + self::LINK_FOLLOWERS => $this->buildApiLink('threads/followers', $thread), + self::LINK_FORUM => $this->buildApiLink('forums', $thread->Forum), + self::LINK_LAST_POSTER => $this->buildApiLink('users', $thread->LastPoster), + self::LINK_LAST_POST => $this->buildApiLink('posts', ['post_id' => $thread->last_post_id]), + self::LINK_PERMALINK => $this->buildPublicLink('threads', $thread), + self::LINK_POSTS => $this->buildApiLink('posts', null, ['thread_id' => $thread->thread_id]), + ]; + + $firstPost = $thread->FirstPost; + if ($firstPost !== null && $firstPost->user_id > 0) { + $firstPostUser = $firstPost->User; + if ($firstPostUser !== null) { + $links[self::LINK_FIRST_POSTER] = $this->buildApiLink('users', $firstPostUser); + $links[self::LINK_FIRST_POSTER_AVATAR] = $firstPostUser->getAvatarUrl('l'); + } + } + + return $links; + } + + public function getMappings(TransformContext $context) + { + return [ + // xf_thread + 'last_post_date' => self::KEY_UPDATE_DATE, + 'node_id' => self::KEY_FORUM_ID, + 'post_date' => self::KEY_CREATE_DATE, + 'reply_count' => self::KEY_POST_COUNT, + 'thread_id' => self::KEY_ID, + 'title' => self::KEY_TITLE, + 'user_id' => self::KEY_CREATOR_USER_ID, + 'username' => self::KEY_CREATOR_USERNAME, + 'view_count' => self::KEY_VIEW_COUNT, + + self::DYNAMIC_KEY_FIELDS, + self::DYNAMIC_KEY_FIRST_POST, + self::DYNAMIC_KEY_FORUM, + self::DYNAMIC_KEY_IS_PUBLISHED, + self::DYNAMIC_KEY_IS_DELETED, + self::DYNAMIC_KEY_IS_STICKY, + self::DYNAMIC_KEY_IS_FOLLOWED, + self::DYNAMIC_KEY_POLL, + self::DYNAMIC_KEY_PREFIXES, + self::DYNAMIC_KEY_TAGS, + self::DYNAMIC_KEY_USER_IS_IGNORED, + ]; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_FIRST_POST)) { + $this->callOnTransformEntitiesForRelation( + $context, + $entities, + self::DYNAMIC_KEY_FIRST_POST, + 'FirstPost' + ); + } + + return $entities; + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $forumFinder = new ParentFinder($finder, 'Forum'); + $visitor = \XF::visitor(); + + $forumFinder->with('Node.Permissions|' . $visitor->permission_combination_id); + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_FIRST_POST)) { + $this->callOnTransformFinderForRelation( + $context, + $finder, + self::DYNAMIC_KEY_FIRST_POST, + 'FirstPost' + ); + } + + if ($context->selectorShouldIncludeField(self::DYNAMIC_KEY_FORUM) + || ($context->data(self::DYNAMIC_KEY_FORUM_DATA_KEY) === true + && !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_FORUM))) { + $this->callOnTransformFinderForRelation( + $context, + $finder, + self::DYNAMIC_KEY_FORUM, + 'Forum' + ); + } + + $userId = $visitor->user_id; + if ($userId > 0) { + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_IS_FOLLOWED)) { + $finder->with('Watch|' . $userId); + } + } + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/XF/Transform/ThreadPrefix.php b/XF/Transform/ThreadPrefix.php new file mode 100644 index 00000000..ebc587ff --- /dev/null +++ b/XF/Transform/ThreadPrefix.php @@ -0,0 +1,25 @@ + self::KEY_ID, + 'title' => self::KEY_TITLE + ]; + } +} diff --git a/XF/Transform/User.php b/XF/Transform/User.php new file mode 100644 index 00000000..d415db20 --- /dev/null +++ b/XF/Transform/User.php @@ -0,0 +1,437 @@ +getSource(); + $visitor = \XF::visitor(); + + switch ($key) { + case self::DYNAMIC_KEY_DOB_DAY: + if ($context->data('flagFullAccess') !== true) { + return null; + } + $userProfile = $user->Profile; + return $userProfile !== null ? $userProfile->dob_day : null; + case self::DYNAMIC_KEY_DOB_MONTH: + if ($context->data('flagFullAccess') !== true) { + return null; + } + $userProfile = $user->Profile; + return $userProfile !== null ? $userProfile->dob_month : null; + case self::DYNAMIC_KEY_DOB_YEAR: + if ($context->data('flagFullAccess') !== true) { + return null; + } + $userProfile = $user->Profile; + return $userProfile !== null ? $userProfile->dob_year : null; + case self::DYNAMIC_KEY_EMAIL: + if ($context->data('flagFullAccess') !== true) { + return null; + } + return $user->email; + case self::DYNAMIC_KEY_EXTERNAL_AUTHS: + return $this->collectExternalAuths($context); + case self::DYNAMIC_KEY_FIELDS: + return $this->collectFields($context); + case self::DYNAMIC_KEY_FOLLOWERS_TOTAL: + if (!$context->selectorShouldIncludeField($key)) { + return null; + } + + /** @var UserFollow $userFollowRepo */ + $userFollowRepo = $this->app->repository('XF:UserFollow'); + return $userFollowRepo->findFollowersForProfile($user)->total(); + case self::DYNAMIC_KEY_FOLLOWINGS_TOTAL: + if (!$context->selectorShouldIncludeField($key)) { + return null; + } + + /** @var UserFollow $userFollowRepo */ + $userFollowRepo = $this->app->repository('XF:UserFollow'); + return $userFollowRepo->findFollowingForProfile($user)->total(); + case self::DYNAMIC_KEY_GROUPS: + return $this->collectGroups($context, $key); + case self::DYNAMIC_KEY_HAS_PASSWORD: + if ($context->data('flagFullAccess') !== true) { + return null; + } + $userAuth = $user->Auth; + if ($userAuth === null) { + return false; + } + + $handler = $userAuth->getAuthenticationHandler(); + return $handler !== null ? $handler->hasPassword() : false; + case self::DYNAMIC_KEY_IS_FOLLOWED: + return $visitor->isFollowing($user); + case self::DYNAMIC_KEY_IS_IGNORED: + return $visitor->isIgnoring($user->user_id); + case self::DYNAMIC_KEY_IS_VALID: + return (!$user->is_banned + && in_array( + $user->user_state, + ['valid', 'email_confirm', 'email_confirm_edit'], + true + ) + ); + case self::DYNAMIC_KEY_IS_VERIFIED: + return $user->user_state === 'valid'; + case self::DYNAMIC_KEY_IS_VISITOR: + return $user->user_id === $visitor->user_id; + case self::DYNAMIC_KEY_LAST_SEEN_DATE: + return $user->canViewCurrentActivity() ? $user->last_activity : $user->register_date; + case self::DYNAMIC_KEY_PERMISSIONS_EDIT: + return $this->collectPermissionsEdit(); + case self::DYNAMIC_KEY_PERMISSIONS_SELF: + return $this->collectPermissionsSelf($context); + case self::DYNAMIC_KEY_TIMEZONE_OFFSET: + if ($context->data('flagFullAccess') !== true) { + return null; + } + $dtz = new \DateTimeZone($user->timezone); + try { + $dt = new \DateTime('now', $dtz); + } catch (\Exception $e) { + return null; + } + return $dtz->getOffset($dt); + case self::DYNAMIC_KEY_TITLE: + return strip_tags($this->getTemplater()->func('user_title', [$user])); + case self::DYNAMIC_KEY_UNREAD_CONVO_COUNT: + if ($context->data('flagFullAccess') !== true || + !$this->checkSessionScope(Server::SCOPE_PARTICIPATE_IN_CONVERSATIONS)) { + return null; + } + return $user->conversations_unread; + case self::DYNAMIC_KEY_UNREAD_NOTIF_COUNT: + if ($context->data('flagFullAccess') !== true) { + return null; + } + return $user->alerts_unread; + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + + $links = [ + self::LINK_AVATAR => $user->getAvatarUrl('l'), + self::LINK_AVATAR_BIG => $user->getAvatarUrl('o'), + self::LINK_AVATAR_SMALL => $user->getAvatarUrl('s'), + self::LINK_DETAIL => $this->buildApiLink('users', $user), + self::LINK_FOLLOWERS => $this->buildApiLink('users/followers', $user), + self::LINK_FOLLOWINGS => $this->buildApiLink('users/followings', $user), + self::LINK_IGNORE => $this->buildApiLink('users/ignore', $user), + self::LINK_PERMALINK => $this->buildPublicLink('members', $user) + ]; + + if ($user->canViewPostsOnProfile()) { + $links[self::LINK_TIMELINE] = $this->buildApiLink('users/timeline', $user); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + $visitor = \XF::visitor(); + + return [ + self::PERM_EDIT => $context->data('flagFullAccess') !== true, + self::PERM_IGNORE => $visitor->canIgnoreUser($user), + self::PERM_FOLLOW => $visitor->canFollowUser($user), + self::PERM_PROFILE_POST => $user->canPostOnProfile() + ]; + } + + public function getMappings(TransformContext $context) + { + return [ + 'reaction_score' => self::KEY_LIKE_COUNT, + 'message_count' => self::KEY_MESSAGE_COUNT, + 'register_date' => self::KEY_REGISTER_DATE, + 'user_id' => self::KEY_ID, + 'username' => self::KEY_NAME, + + self::DYNAMIC_KEY_DOB_DAY, + self::DYNAMIC_KEY_DOB_MONTH, + self::DYNAMIC_KEY_DOB_YEAR, + self::DYNAMIC_KEY_EMAIL, + self::DYNAMIC_KEY_EXTERNAL_AUTHS, + self::DYNAMIC_KEY_FIELDS, + self::DYNAMIC_KEY_FOLLOWERS_TOTAL, + self::DYNAMIC_KEY_FOLLOWINGS_TOTAL, + self::DYNAMIC_KEY_GROUPS, + self::DYNAMIC_KEY_HAS_PASSWORD, + self::DYNAMIC_KEY_IS_FOLLOWED, + self::DYNAMIC_KEY_IS_IGNORED, + self::DYNAMIC_KEY_IS_VALID, + self::DYNAMIC_KEY_IS_VERIFIED, + self::DYNAMIC_KEY_IS_VISITOR, + self::DYNAMIC_KEY_LAST_SEEN_DATE, + self::DYNAMIC_KEY_PERMISSIONS_EDIT, + self::DYNAMIC_KEY_PERMISSIONS_SELF, + self::DYNAMIC_KEY_TIMEZONE_OFFSET, + self::DYNAMIC_KEY_TITLE, + self::DYNAMIC_KEY_UNREAD_CONVO_COUNT, + self::DYNAMIC_KEY_UNREAD_NOTIF_COUNT + ]; + } + + public function onNewContext(TransformContext $context) + { + $data = parent::onNewContext($context); + $data['flagFullAccess'] = false; + + $visitor = \XF::visitor(); + if ($visitor->user_id > 0) { + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + if ($user->user_id === $visitor->user_id) { + $data['flagFullAccess'] = true; + } else { + $data['flagFullAccess'] = $visitor->hasAdminPermission('user'); + } + } + + return $data; + } + + /** + * @param TransformContext $context + * @return array|null + */ + protected function collectExternalAuths($context) + { + if ($context->data('flagFullAccess') !== true) { + return null; + } + + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + $userProfile = $user->Profile; + if ($userProfile === null) { + return null; + } + + $data = []; + foreach ($userProfile->connected_accounts as $provider => $providerKey) { + $data[] = [ + 'provider' => $provider, + 'provider_key' => $providerKey + ]; + } + + return $data; + } + + /** + * @param TransformContext $context + * @return array|null + */ + protected function collectFields($context) + { + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + + $systemFields = ['about', 'homepage', 'location']; + $profile = $user->Profile; + $data = $profile != null + ? $this->transformer->transformCustomFieldSet($context, $profile->custom_fields) + : []; + + foreach ($systemFields as $systemFieldId) { + $systemField = [ + 'id' => $systemFieldId, + 'title' => \XF::phrase($systemFieldId), + 'description' => '', + 'position' => 'personal', + 'is_required' => false + ]; + + switch ($systemFieldId) { + case 'about': + $systemField['value'] = $profile !== null ? $profile->about : null; + break; + case 'homepage': + $systemField['value'] = $profile !== null ? $profile->website : null; + $systemField['title'] = \XF::phrase('website'); + break; + case 'location': + $systemField['value'] = $profile !== null ? $profile->location : null; + break; + } + + $data[] = $systemField; + } + + return $this->transformer->transformArray($context, 'fields', $data); + } + + /** + * @param TransformContext $context + * @param string $key + * @return array|null + */ + protected function collectGroups($context, $key) + { + if ($context->data('flagFullAccess') !== true) { + return null; + } + + static $allGroups = null; + if ($allGroups === null) { + /** @var UserGroup $userGroupRepo */ + $userGroupRepo = $this->app->repository('XF:UserGroup'); + $allGroups = $userGroupRepo->findUserGroupsForList()->fetch(); + } + + /** @var \XF\Entity\User $user */ + $user = $context->getSource(); + $userGroups = []; + foreach ($allGroups as $group) { + if (!$user->isMemberOf($group)) { + continue; + } + $userGroups[] = $group; + } + + $data = []; + /** @var \XF\Entity\UserGroup $group */ + foreach ($userGroups as $group) { + $groupData = $this->transformer->transformEntity($context, $key, $group); + if ($group->user_group_id === $user->user_group_id) { + $groupData[self::DYNAMIC_KEY_GROUPS__IS_PRIMARY] = true; + } else { + $groupData[self::DYNAMIC_KEY_GROUPS__IS_PRIMARY] = false; + } + + $data[] = $groupData; + } + + return $data; + } + + /** + * @return array|null + */ + protected function collectPermissionsEdit() + { + // TODO + return null; + } + + /** + * @param TransformContext $context + * @return array|null + */ + protected function collectPermissionsSelf($context) + { + if ($context->data('flagFullAccess') !== true) { + return null; + } + + $canStartConversation = \XF::visitor()->canStartConversation(); + $canUploadAndManageAttachments = false; + if ($canStartConversation) { + /** @var ConversationMaster $conversation */ + $conversation = $this->app->em()->create('XF:ConversationMaster'); + $canUploadAndManageAttachments = $conversation->canUploadAndManageAttachments(); + } + + return [ + self::PERM_SELF_CREATE_CONVO => $canStartConversation, + self::PERM_SELF_ATTACH_CONVO => $canUploadAndManageAttachments + ]; + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $finder->with('Privacy'); + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_DOB_DAY) || + !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_DOB_MONTH) || + !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_DOB_YEAR) || + !$context->selectorShouldExcludeField(self::DYNAMIC_KEY_EXTERNAL_AUTHS) + ) { + $finder->with('Profile'); + } + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_HAS_PASSWORD)) { + $finder->with('Auth'); + } + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_LAST_SEEN_DATE)) { + $finder->with('Activity'); + } + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/XF/Transform/UserAlert.php b/XF/Transform/UserAlert.php new file mode 100644 index 00000000..16f11739 --- /dev/null +++ b/XF/Transform/UserAlert.php @@ -0,0 +1,91 @@ + self::KEY_ID, + 'event_date' => self::KEY_CREATE_DATE, + 'user_id' => self::KEY_CREATOR_USER_ID, + 'username' => self::KEY_CREATOR_USERNAME, + 'content_type' => self::KEY_CONTENT_TYPE, + 'content_id' => self::KEY_CONTENT_ID, + 'action' => self::KEY_CONTENT_ACTION, + + + self::DYNAMIC_KEY_IS_UNREAD, + self::DYNAMIC_KEY_NOTIFICATION_TYPE, + self::DYNAMIC_KEY_NOTIFICATION_HTML + ]; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XF\Entity\UserAlert $alert */ + $alert = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_IS_UNREAD: + return $alert->isUnviewed(); + case self::DYNAMIC_KEY_NOTIFICATION_TYPE: + return sprintf( + '%s_%d_%s', + $alert->content_type, + $alert->content_id, + $alert->action + ); + case self::DYNAMIC_KEY_NOTIFICATION_HTML: + return $alert->render(); + } + + return parent::calculateDynamicValue($context, $key); + } + + public function collectLinks(TransformContext $context) + { + /** @var \XF\Entity\UserAlert $alert */ + $alert = $context->getSource(); + + $links = []; + + $links['content'] = $this->buildApiLink('notifications/content', $alert); + $links['read'] = $this->buildApiLink('notifications/read'); + + $user = $alert->User; + if ($user !== null) { + $links['creator_avatar'] = $user->getAvatarUrl('m'); + } + + return $links; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + /** @var \XF\Entity\UserAlert[] $userAlerts */ + $userAlerts = $entities; + + /** @var \XF\Repository\UserAlert $userAlertRepo */ + $userAlertRepo = $this->app->repository('XF:UserAlert'); + $userAlertRepo->addContentToAlerts($userAlerts); + + return parent::onTransformEntities($context, $entities); + } +} diff --git a/XF/Transform/UserGroup.php b/XF/Transform/UserGroup.php new file mode 100644 index 00000000..b583264d --- /dev/null +++ b/XF/Transform/UserGroup.php @@ -0,0 +1,25 @@ + self::KEY_ID, + 'title' => self::KEY_TITLE + ]; + } +} diff --git a/XFRM/Controller/Category.php b/XFRM/Controller/Category.php new file mode 100644 index 00000000..e1708186 --- /dev/null +++ b/XFRM/Controller/Category.php @@ -0,0 +1,65 @@ +resource_category_id) { + return $this->actionSingle($params->resource_category_id); + } + + $finder = $this->finder('XFRM:Category')->order('lft'); + $categories = $this->transformFinderLazily($finder); + + return $this->api(['categories' => $categories]); + } + + /** + * @param int $resourceCategoryId + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + protected function actionSingle($resourceCategoryId) + { + $category = $this->assertViewableCategory($resourceCategoryId); + + $data = [ + 'category' => $this->transformEntityLazily($category) + ]; + + return $this->api($data); + } + + /** + * @param int $resourceCategoryId + * @param array $extraWith + * @return \XFRM\Entity\Category + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableCategory($resourceCategoryId, array $extraWith = []) + { + /** @var \XFRM\Entity\Category $category */ + $category = $this->assertRecordExists( + 'XFRM:Category', + $resourceCategoryId, + $extraWith, + 'xfmg_requested_category_not_found' + ); + + if (!$category->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $category; + } +} diff --git a/XFRM/Controller/ResourceItem.php b/XFRM/Controller/ResourceItem.php new file mode 100644 index 00000000..37f954b5 --- /dev/null +++ b/XFRM/Controller/ResourceItem.php @@ -0,0 +1,327 @@ +resource_id) { + return $this->actionSingle($params->resource_id); + } + + $params = $this->params() + ->define('resource_category_id', 'uint', 'category id to filter') + ->define('resource_category_ids', 'str', 'category ids to filter (separated by comma)') + ->define('in_sub', 'bool', 'flag to include sub categories in filtering') + ->defineOrder([ + 'resource_create_date' => ['resource_date', 'asc'], + 'resource_create_date_reverse' => ['resource_date', 'desc'], + 'resource_update_date' => ['last_update', 'asc', '_whereOp' => '>'], + 'resource_update_date_reverse' => ['last_update', 'desc', '_whereOp' => '<'], + 'resource_download_count' => ['download_count', 'asc'], + 'resource_download_count_reverse' => ['download_count', 'desc'], + 'resource_rating_weighted' => ['rating_weighted', 'asc'], + 'resource_rating_weighted_reverse' => ['rating_weighted', 'desc'], + ]) + ->definePageNav() + ->define(\Xfrocks\Api\XFRM\Transform\ResourceItem::KEY_UPDATE_DATE, 'uint', 'timestamp to filter') + ->define('resource_ids', 'str', 'resource ids to fetch (ignoring all filters, separated by comma)'); + + if ($params['resource_ids'] !== '') { + return $this->actionMultiple($params->filterCommaSeparatedIds('resource_ids')); + } + + /** @var \XFRM\Finder\ResourceItem $finder */ + $finder = $this->finder('XFRM:ResourceItem'); + $this->applyFilters($finder, $params); + + $orderChoice = $params->sortFinder($finder); + if (is_array($orderChoice)) { + switch ($orderChoice[0]) { + case 'last_update': + $keyUpdateDate = \Xfrocks\Api\XFRM\Transform\ResourceItem::KEY_UPDATE_DATE; + if ($params[$keyUpdateDate] > 0) { + $finder->where($orderChoice[0], $orderChoice['_whereOp'], $params[$keyUpdateDate]); + } + break; + } + } + + $params->limitFinderByPage($finder); + + $total = $finder->total(); + $resources = $total > 0 ? $this->transformFinderLazily($finder) : []; + + $data = [ + 'resources' => $resources, + 'resources_total' => $total, + ]; + + $theCategory = null; + if ($params['resource_category_id'] > 0) { + /** @var \XFRM\Entity\Category $theCategory */ + $theCategory = $this->assertRecordExists('XFRM:Category', $params['resource_category_id']); + } + if ($theCategory !== null) { + $this->transformEntityIfNeeded($data, 'category', $theCategory); + } + + PageNav::addLinksToData($data, $params, $total, 'resources'); + + return $this->api($data); + } + + /** + * @param array $ids + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionMultiple(array $ids) + { + $resources = []; + if (count($ids) > 0) { + $resources = $this->findAndTransformLazily('XFRM:ResourceItem', $ids); + } + + return $this->api(['resources' => $resources]); + } + + + /** + * @param int $resourceId + * @return \Xfrocks\Api\Mvc\Reply\Api + */ + public function actionSingle($resourceId) + { + return $this->api([ + 'resource' => $this->findAndTransformLazily( + 'XFRM:ResourceItem', + intval($resourceId), + 'xfrm_requested_resource_not_found' + ) + ]); + } + + /** + * @param ParameterBag $params + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowers(ParameterBag $params) + { + $resource = $this->assertViewableResource($params->resource_id); + + $users = []; + if ($resource->canWatch()) { + $visitor = \XF::visitor(); + /** @var ResourceWatch|null $watch */ + $watch = $resource->Watch[$visitor->user_id]; + if ($watch !== null) { + $users[] = [ + 'user_id' => $visitor->user_id, + 'username' => $visitor->username, + 'follow' => [ + 'alert' => true, + 'email' => $watch->email_subscribe + ] + ]; + } + } + + $data = [ + 'users' => $users + ]; + + return $this->api($data); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionPostFollowers(ParameterBag $params) + { + $resource = $this->assertViewableResource($params->resource_id); + + $params = $this + ->params() + ->define('email', 'bool', 'whether to receive notification as email'); + + if (!$resource->canWatch($error)) { + return $this->noPermission($error); + } + + /** @var \XFRM\Repository\ResourceWatch $resourceWatchRepo */ + $resourceWatchRepo = $this->repository('XFRM:ResourceWatch'); + $resourceWatchRepo->setWatchState( + $resource, + \XF::visitor(), + 'watch', + ['email_subscribe' => $params['email']] + ); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @param ParameterBag $params + * @return \XF\Mvc\Reply\Message + * @throws \XF\Mvc\Reply\Exception + */ + public function actionDeleteFollowers(ParameterBag $params) + { + $resource = $this->assertViewableResource($params->resource_id); + + /** @var \XFRM\Repository\ResourceWatch $resourceWatchRepo */ + $resourceWatchRepo = $this->repository('XFRM:ResourceWatch'); + $resourceWatchRepo->setWatchState($resource, \XF::visitor(), 'delete'); + + return $this->message(\XF::phrase('changes_saved')); + } + + /** + * @return \Xfrocks\Api\Mvc\Reply\Api + * @throws \XF\Mvc\Reply\Exception + */ + public function actionGetFollowed() + { + $params = $this + ->params() + ->define('total', 'uint') + ->definePageNav(); + + $this->assertRegistrationRequired(); + + $visitor = \XF::visitor(); + $finder = $this->finder('XFRM:ResourceWatch') + ->where('user_id', $visitor->user_id); + + if ($params['total'] > 0) { + $data = [ + 'resources_total' => $finder->total() + ]; + + return $this->api($data); + } + + $finder->with('Resource'); + $params->limitFinderByPage($finder); + + /** @var ResourceWatch[] $resourceWatches */ + $resourceWatches = $finder->fetch(); + + + $context = $this->params()->getTransformContext(); + $context->onTransformedCallbacks[] = function ($context, &$data) use ($resourceWatches) { + /** @var TransformContext $context */ + $source = $context->getSource(); + if (!($source instanceof \XFRM\Entity\ResourceItem)) { + return; + } + + /** @var ResourceWatch|null $watched */ + $watched = null; + foreach ($resourceWatches as $resourceWatch) { + if ($resourceWatch->resource_id == $source->resource_id) { + $watched = $resourceWatch; + break; + } + } + + if ($watched !== null) { + $data['follow'] = [ + 'alert' => true, + 'email' => $watched->email_subscribe + ]; + } + }; + + $resources = []; + foreach ($resourceWatches as $resourceWatch) { + $resource = $resourceWatch->Resource; + if ($resource !== null) { + $resources[] = $this->transformEntityLazily($resource); + } + } + + $total = $finder->total(); + + $data = [ + 'resources' => $resources, + 'resources_total' => $total + ]; + + PageNav::addLinksToData($data, $params, $total, 'resources/followed'); + + return $this->api($data); + } + + /** + * @param \XFRM\Finder\ResourceItem $finder + * @param Params $params + * @return void + */ + protected function applyFilters($finder, Params $params) + { + $finder->applyGlobalVisibilityChecks(); + + $categoryIds = []; + if ($params['resource_category_id'] > 0) { + $categoryIds[] = $params['resource_category_id']; + } else { + $categoryIds = $params->filterCommaSeparatedIds('resource_category_ids'); + if (count($categoryIds) > 0) { + sort($categoryIds); + } + } + if (count($categoryIds) > 0) { + if ($params['in_sub'] === true) { + /** @var \XFRM\Repository\Category $categoryRepo */ + $categoryRepo = $this->repository('XFRM:Category'); + $categories = $this->finder('XFRM:Category')->fetch(); + $categoryTree = $categoryRepo->createCategoryTree($categories); + $categoryIds = Tree::getAllChildIds($categoryTree, $categoryIds); + } + + $categoryIds = array_unique($categoryIds); + $finder->where('resource_category_id', $categoryIds); + } + } + + /** + * @param int $resourceId + * @param array $extraWith + * @return \XFRM\Entity\ResourceItem + * @throws \XF\Mvc\Reply\Exception + */ + protected function assertViewableResource($resourceId, array $extraWith = []) + { + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + $resourceItem = $this->assertRecordExists( + 'XFRM:ResourceItem', + $resourceId, + $extraWith, + 'xfrm_requested_resource_not_found' + ); + + if (!$resourceItem->canView($error)) { + throw $this->exception($this->noPermission($error)); + } + + return $resourceItem; + } +} diff --git a/XFRM/Controller/Search.php b/XFRM/Controller/Search.php new file mode 100644 index 00000000..5b1a7cf9 --- /dev/null +++ b/XFRM/Controller/Search.php @@ -0,0 +1,42 @@ +canSearch($error)) { + return $this->noPermission($error); + } + + $params = $this + ->params() + ->define('q', 'str', 'query to search for') + ->define('user_id', 'uint', 'id of the creator to search for contents'); + + if ($params['q'] === '' && $params['user_id'] === 0) { + return $this->error(\XF::phrase('bdapi_slash_search_requires_q'), 400); + } + + $search = $this->searchRepo()->search($params, 'resource'); + if ($search === null) { + // no results. + return $this->error(\XF::phrase('no_results_found'), 400); + } + + return $this->rerouteController(__CLASS__, 'getResults', ['search_id' => $search->search_id]); + } +} + +if (false) { + // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses + // phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps + class XFCP_Search extends \Xfrocks\Api\Controller\Search + { + } +} diff --git a/XFRM/Data/Modules.php b/XFRM/Data/Modules.php new file mode 100644 index 00000000..b954578f --- /dev/null +++ b/XFRM/Data/Modules.php @@ -0,0 +1,51 @@ +addController( + 'Xfrocks\Api\XFRM\Controller\Category', + 'resource-categories', + ':int/' + ); + $this->addController( + 'Xfrocks\Api\XFRM\Controller\ResourceItem', + 'resources', + ':int/' + ); + + $this->register('resource', 2017040401); + } + + /** + * @param AbstractController $controller + * @return array + */ + public function getDataForApiIndex($controller) + { + $data = parent::getDataForApiIndex($controller); + + $app = $controller->app(); + $apiRouter = $app->router(Listener::$routerType); + $data['links']['resource-categories'] = $apiRouter->buildLink('resource-categories'); + $data['links']['resources'] = $apiRouter->buildLink('resources'); + + return $data; + } +} + +if (false) { + // phpcs:disable PSR1.Classes.ClassDeclaration.MultipleClasses + // phpcs:disable Squiz.Classes.ValidClassName.NotCamelCaps + class XFCP_Modules extends \Xfrocks\Api\Data\Modules + { + } +} diff --git a/XFRM/Transform/Category.php b/XFRM/Transform/Category.php new file mode 100644 index 00000000..042b03ee --- /dev/null +++ b/XFRM/Transform/Category.php @@ -0,0 +1,104 @@ +getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_FIELDS: + /** @var \XF\CustomField\DefinitionSet $allDefinitions */ + $allDefinitions = $this->app->container('customFields.resources'); + $categoryDefinitions = $allDefinitions->filterOnly($category->field_cache); + return $this->transformer->transformCustomFieldDefinitionSet($context, $categoryDefinitions); + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XFRM\Entity\Category $category */ + $category = $context->getSource(); + + $links = [ + self::LINK_DETAIL => $this->buildApiLink('resource-categories', $category), + self::LINK_PERMALINK => $this->buildPublicLink('resources/categories', $category), + self::LINK_RESOURCES => $this->buildApiLink( + 'resources', + null, + ['resource_category_id' => $category->resource_category_id] + ), + self::LINK_RESOURCES_IN_SUB => $this->buildApiLink( + 'resources', + null, + ['resource_category_id' => $category->resource_category_id, 'in_sub' => 1] + ), + ]; + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XFRM\Entity\Category $category */ + $category = $context->getSource(); + + $permissions = [ + self::PERM_ADD => $category->canAddResource(), + ]; + + $permissions += [ + self::PERM_ADD_FILE => $permissions[self::PERM_ADD] && $category->allow_local, + self::PERM_ADD_URL => $permissions[self::PERM_ADD] && ($category->allow_external || $category->allow_commercial_external), + self::PERM_ADD_PRICE => $permissions[self::PERM_ADD] && $category->allow_commercial_external, + self::PERM_ADD_FILE_LESS => $permissions[self::PERM_ADD] && $category->allow_fileless, + ]; + + return $permissions; + } + + public function getMappings(TransformContext $context) + { + return [ + 'description' => self::KEY_DESCRIPTION, + 'parent_category_id' => self::KEY_PARENT_ID, + 'resource_category_id' => self::KEY_ID, + 'resource_count' => self::KEY_RESOURCE_COUNT, + 'title' => self::KEY_TITLE, + + self::DYNAMIC_KEY_FIELDS, + ]; + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $visitor = \XF::visitor(); + + $finder->with('Permissions|' . $visitor->permission_combination_id); + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/XFRM/Transform/ResourceItem.php b/XFRM/Transform/ResourceItem.php new file mode 100644 index 00000000..b4fc0b52 --- /dev/null +++ b/XFRM/Transform/ResourceItem.php @@ -0,0 +1,356 @@ +getParentSourceValue('resource_id'); + } + + return null; + } + + public function attachmentCollectLinks(TransformContext $context, array &$links) + { + $resourceItem = $context->getParentSource(); + $links[self::ATTACHMENT__LINK_RESOURCE] = $this->buildApiLink('resources', $resourceItem); + } + + public function attachmentCollectPermissions(TransformContext $context, array &$permissions) + { + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + $resourceItem = $context->getParentSource(); + $canDelete = false; + + $category = $resourceItem->Category; + $description = $resourceItem->Description; + if ($category !== null + && $description !== null + && $resourceItem->canEdit() + && $category->canUploadAndManageUpdateImages() + ) { + $canDelete = $this->checkAttachmentCanManage(self::CONTENT_TYPE_RESOURCE_UPDATE, $description); + } + + $permissions[self::PERM_DELETE] = $canDelete; + } + + public function attachmentGetMappings(TransformContext $context, array &$mappings) + { + $mappings[] = self::ATTACHMENT__DYNAMIC_KEY_ID; + } + + public function calculateDynamicValue(TransformContext $context, $key) + { + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + $resourceItem = $context->getSource(); + + switch ($key) { + case self::DYNAMIC_KEY_ATTACHMENT_COUNT: + $description = $resourceItem->Description; + if ($description !== null) { + return $description->attach_count; + } + break; + case self::DYNAMIC_KEY_ATTACHMENTS: + $description = $resourceItem->Description; + if ($description === null || $description->attach_count < 1) { + return null; + } + + return $this->transformer->transformEntityRelation($context, $key, $description, 'Attachments'); + case self::DYNAMIC_KEY_CURRENCY: + return strlen($resourceItem->external_purchase_url) > 0 ? $resourceItem->currency : null; + case self::DYNAMIC_KEY_FIELDS: + $resourceFields = $resourceItem->custom_fields; + return $this->transformer->transformCustomFieldSet($context, $resourceFields); + case self::DYNAMIC_KEY_HAS_FILE: + return $resourceItem->getResourceTypeDetailed() === 'download_local'; + case self::DYNAMIC_KEY_HAS_URL: + if (strlen($resourceItem->external_purchase_url) > 0) { + return true; + } + + if ($resourceItem->getResourceTypeDetailed() === 'download_external') { + $version = $resourceItem->CurrentVersion; + if ($version !== null) { + return $version->download_url; + } + } + + return false; + case self::DYNAMIC_KEY_IS_DELETED: + return $resourceItem->resource_state === 'deleted'; + case self::DYNAMIC_KEY_IS_FOLLOWED: + $userId = \XF::visitor()->user_id; + if ($userId < 1) { + return false; + } + + return isset($resourceItem->Watch[$userId]); + case self::DYNAMIC_KEY_IS_LIKED: + $description = $resourceItem->Description; + if ($description !== null) { + return $description->isReactedTo(); + } + break; + case self::DYNAMIC_KEY_IS_PUBLISHED: + return $resourceItem->resource_state === 'visible'; + case self::DYNAMIC_KEY_LIKE_COUNT: + $description = $resourceItem->Description; + if ($description !== null) { + return $description->reaction_score; + } + break; + case self::DYNAMIC_KEY_PRICE: + return strlen($resourceItem->external_purchase_url) > 0 ? $resourceItem->price : null; + case self::DYNAMIC_KEY_RATING: + $count = $resourceItem->rating_count; + if ($count == 0) { + return 0; + } + + $average = $resourceItem->rating_sum / $count; + $average = round($average / 0.5, 0) * 0.5; + + return $average; + case self::DYNAMIC_KEY_TAGS: + return $this->transformer->transformTags($context, $resourceItem->tags); + case self::DYNAMIC_KEY_TEXT: + $description = $resourceItem->Description; + if ($description !== null) { + return $description->message; + } + break; + case self::DYNAMIC_KEY_TEXT_HTML: + $description = $resourceItem->Description; + if ($description !== null) { + return $this->renderBbCodeHtml($key, $description->message, $description); + } + break; + case self::DYNAMIC_KEY_TEXT_PLAIN: + $description = $resourceItem->Description; + if ($description !== null) { + return $this->renderBbCodePlainText($description->message); + } + break; + case self::DYNAMIC_KEY_VERSION: + $version = $resourceItem->CurrentVersion; + if ($version !== null) { + return $version->version_string; + } + break; + } + + return null; + } + + public function collectLinks(TransformContext $context) + { + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + $resourceItem = $context->getSource(); + $user = $resourceItem->User; + + $links = [ + self::LINK_ATTACHMENTS => $this->buildApiLink('resources/attachments', $resourceItem), + self::LINK_CATEGORY => $this->buildApiLink('resource-categories', $resourceItem->Category), + self::LINK_CREATOR_AVATAR => $user !== null ? $user->getAvatarUrl('l') : null, + self::LINK_DETAIL => $this->buildApiLink('resources', $resourceItem), + self::LINK_FOLLOWERS => $this->buildApiLink('resources/followers', $resourceItem), + self::LINK_ICON => $resourceItem->getIconUrl(), + self::LINK_LIKES => $this->buildApiLink('resources/likes', $resourceItem), + self::LINK_PERMALINK => $this->buildPublicLink('resources', $resourceItem), + self::LINK_RATINGS => $this->buildApiLink('resources/ratings', $resourceItem), + self::LINK_REPORT => $this->buildApiLink('resources/report', $resourceItem), + ]; + + if (strlen($resourceItem->external_purchase_url) > 0) { + $links[self::LINK_CONTENT] = $resourceItem->external_purchase_url; + } else { + $resourceTypeDetailed = $resourceItem->getResourceTypeDetailed(); + switch ($resourceTypeDetailed) { + case 'download_external': + $version = $resourceItem->CurrentVersion; + if ($version !== null) { + $links[self::LINK_CONTENT] = $version->download_url; + } + break; + case 'download_local': + $links[self::LINK_CONTENT] = $this->buildApiLink('resources/files', $resourceItem); + break; + } + } + + if ($resourceItem->discussion_thread_id > 0) { + $links[self::LINK_THREAD] = $this->buildApiLink( + 'threads', + ['thread_id' => $resourceItem->discussion_thread_id] + ); + } + + return $links; + } + + public function collectPermissions(TransformContext $context) + { + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + $resourceItem = $context->getSource(); + $description = $resourceItem->Description; + + return [ + self::PERM_ADD_ICON => $resourceItem->canEdit(), + self::PERM_DELETE => $resourceItem->canDelete(), + self::PERM_DOWNLOAD => $resourceItem->canDownload(), + self::PERM_EDIT => $resourceItem->canEdit(), + self::PERM_FOLLOW => $resourceItem->canWatch(), + self::PERM_LIKE => $description !== null ? $description->canReact() : null, + self::PERM_RATE => $resourceItem->canRate(), + self::PERM_REPORT => $description !== null ? $description->canReport() : null, + ]; + } + + public function getMappings(TransformContext $context) + { + return [ + // xf_rm_resource + 'download_count' => self::KEY_DOWNLOAD_COUNT, + 'last_update' => self::KEY_UPDATE_DATE, + 'rating_count' => self::KEY_RATING_COUNT, + 'rating_sum' => self::KEY_RATING_SUM, + 'rating_avg' => self::KEY_RATING_AVG, + 'rating_weighted' => self::KEY_RATING_WEIGHTED, + 'resource_category_id' => self::KEY_CATEGORY_ID, + 'resource_date' => self::KEY_CREATE_DATE, + 'resource_id' => self::KEY_ID, + 'tag_line' => self::KEY_DESCRIPTION, + 'title' => self::KEY_TITLE, + 'user_id' => self::KEY_CREATOR_USER_ID, + 'username' => self::KEY_CREATOR_USERNAME, + + self::DYNAMIC_KEY_ATTACHMENT_COUNT, + self::DYNAMIC_KEY_ATTACHMENTS, + self::DYNAMIC_KEY_CURRENCY, + self::DYNAMIC_KEY_FIELDS, + self::DYNAMIC_KEY_HAS_FILE, + self::DYNAMIC_KEY_HAS_URL, + self::DYNAMIC_KEY_IS_DELETED, + self::DYNAMIC_KEY_IS_FOLLOWED, + self::DYNAMIC_KEY_IS_LIKED, + self::DYNAMIC_KEY_IS_PUBLISHED, + self::DYNAMIC_KEY_LIKE_COUNT, + self::DYNAMIC_KEY_PRICE, + self::DYNAMIC_KEY_RATING, + self::DYNAMIC_KEY_TAGS, + self::DYNAMIC_KEY_TEXT, + self::DYNAMIC_KEY_TEXT_HTML, + self::DYNAMIC_KEY_TEXT_PLAIN, + self::DYNAMIC_KEY_VERSION, + ]; + } + + public function onTransformEntities(TransformContext $context, $entities) + { + $needAttachments = false; + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_ATTACHMENTS)) { + $needAttachments = true; + } + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_TEXT_HTML)) { + $needAttachments = true; + } + if ($needAttachments) { + $descriptions = []; + /** @var \XFRM\Entity\ResourceItem $resourceItem */ + foreach ($entities as $resourceItem) { + $description = $resourceItem->Description; + if ($description !== null) { + $descriptions[$description->resource_update_id] = $description; + } + } + + $this->enqueueEntitiesToAddAttachmentsTo($descriptions, self::CONTENT_TYPE_RESOURCE_UPDATE); + } + + return $entities; + } + + public function onTransformFinder(TransformContext $context, \XF\Mvc\Entity\Finder $finder) + { + $categoryFinder = new ParentFinder($finder, 'Category'); + $visitor = \XF::visitor(); + + $categoryFinder->with('Permissions|' . $visitor->permission_combination_id); + + $finder->with('CurrentVersion'); + $finder->with('Description'); + $finder->with('User'); + + $userId = $visitor->user_id; + if ($userId > 0) { + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_IS_FOLLOWED)) { + $finder->with('Watch|' . $userId); + } + + if (!$context->selectorShouldExcludeField(self::DYNAMIC_KEY_IS_LIKED)) { + $finder->with('Description.Reactions|' . $userId); + } + } + + return parent::onTransformFinder($context, $finder); + } +} diff --git a/xenforo/api/bootstrap.php b/_files/api/bootstrap.php similarity index 55% rename from xenforo/api/bootstrap.php rename to _files/api/bootstrap.php index b03d1cb1..0496e555 100644 --- a/xenforo/api/bootstrap.php +++ b/_files/api/bootstrap.php @@ -1,7 +1,5 @@ setupAutoloader($fileDir . '/library'); - -XenForo_Application::initialize($fileDir . '/library', $fileDir); -XenForo_Application::set('page_start_time', $startTime); +/** @noinspection PhpIncludeInspection */ +require($fileDir . $pathToXfPhp); +XF::start($fileDir); diff --git a/_files/api/index.php b/_files/api/index.php new file mode 100644 index 00000000..5c763710 --- /dev/null +++ b/_files/api/index.php @@ -0,0 +1,4 @@ + [ + 'node_id' => ['column', 'type' => Entity::UINT], + 'Node' => ['relation', 'type' => Entity::TO_ONE, 'entity' => 'XF:Node'], + ], + 'Xfrocks\Api\Entity\TokenWithScope' => [ + 'scope' => ['column', 'type' => Entity::STR], + 'scopes' => ['getter', 'methodName' => 'getScopes'], + ], + ]; + + public function hasProperty(ClassReflection $classReflection, string $propertyName): bool + { + $className = $classReflection->getName(); + if (!isset($this->map[$className])) { + return false; + } + + $classMap = $this->map[$className]; + if (!isset($classMap[$propertyName])) { + return false; + } + + return true; + } + + public function getProperty(ClassReflection $classReflection, string $propertyName): PropertyReflection + { + $mapEntry = $this->map[$classReflection->getName()][$propertyName]; + + switch ($mapEntry[0]) { + case 'column': + return new EntityColumnReflection($classReflection, $mapEntry['type']); + case 'getter': + if ($classReflection->hasNativeMethod($mapEntry['methodName'])) { + $method = $classReflection->getNativeMethod($mapEntry['methodName']); + return new EntityGetterReflection( + $classReflection, + $method->getVariants()[0]->getReturnType(), + false + ); + } + break; + case 'relation': + return new EntityRelationReflection($classReflection, $mapEntry['type'], $mapEntry['entity']); + } + + throw new \PHPStan\ShouldNotHappenException(); + } +} diff --git a/_files/dev/autogen.json b/_files/dev/autogen.json new file mode 100644 index 00000000..2af36d95 --- /dev/null +++ b/_files/dev/autogen.json @@ -0,0 +1,6 @@ +{ + "Admin/Controller/Entity.php": 2020052301, + "DevHelper\\Cli\\Command\\AutoGen": { + "version_id": 2019041201 + } +} \ No newline at end of file diff --git a/_files/dev/config.json b/_files/dev/config.json new file mode 100644 index 00000000..c13e373f --- /dev/null +++ b/_files/dev/config.json @@ -0,0 +1,5 @@ +{ + "addon_ids_auto_enable": [ + "XFRM" + ] +} diff --git a/_files/dev/phpstan.neon b/_files/dev/phpstan.neon new file mode 100644 index 00000000..028430f1 --- /dev/null +++ b/_files/dev/phpstan.neon @@ -0,0 +1,11 @@ +includes: + - /var/www/html/src/addons/DevHelper/PHPStan/phpstan.neon + +parameters: + checkGenericClassInNonGenericObjectType: false + +services: + - + class: dev\PHPStan\Reflection\PropertiesClassReflectionExtension + tags: + - phpstan.broker.propertiesClassReflectionExtension diff --git a/_files/dev/phpstan.php b/_files/dev/phpstan.php new file mode 100644 index 00000000..b2b1989e --- /dev/null +++ b/_files/dev/phpstan.php @@ -0,0 +1,15 @@ +addPsr4('dev\PHPStan\\', __DIR__ . '/PHPStan'); + +$extensionHintManualPath = realpath(__DIR__ . '/../../_output/extension_hint_manual.php'); +$loader->addClassMap([ + 'Xfrocks\Api\XFRM\Data\XFCP_Modules' => $extensionHintManualPath, +]); + +return $loader; diff --git a/docs/api.markdown b/_files/docs/api.markdown similarity index 90% rename from docs/api.markdown rename to _files/docs/api.markdown index a09a2c5f..7d84acb3 100755 --- a/docs/api.markdown +++ b/_files/docs/api.markdown @@ -572,6 +572,7 @@ Parameters: * `post_body` (__required__): content of the new thread. * `thread_prefix_id` (_optional_): id of a prefix for the new thread. Since forum-2016031001. * `thread_tags` (_optional_): thread tags for the new thread. Since forum-2015091002. + * `fields` (_optional_): thread fields for the new thread. Since forum-2018073000 Required scopes: @@ -1061,6 +1062,7 @@ Parameters: * `thread_title` (_optional_, since forum-2014052203): new title of the thread (only used if the post is the first post in the thread and the authenticated user can edit thread). * `thread_prefix_id` (_optional_, since forum-2016031001): new id of the thread's prefix (only used if the post is the first post in the thread and the authenticated user can edit thread). * `thread_tags` (_optional_, since forum-2015091101): new tags of the thread (only used if the post is the first post in the thread and the authenticated user can edit thread tags). +* `fields` (_optional_, since forum-2018082101): new fields of the thread (only used if the post is the first post in the thread) Required scopes: @@ -1537,6 +1539,17 @@ Required scopes: * `post` +### GET `/users/:userId/default-avatar` +Default avatar (binary) of a user. Since forum-2018082301. + +Parameters: + + * N/A + +Required scopes: + + * `read` + ### GET `/users/:userId/followers` List of a user's followers @@ -1664,6 +1677,24 @@ Required scopes: * `post` +### POST `/users/:userId/report` +Report an user. Since forum-2018092501 +``` +{ + status: "ok", + message: "Changes Saved" +} +``` + + + Parameters: + + * `message` (__required__): reason of the report + + Required scopes: + + * `post` + ### GET `/users/groups` List of all user groups. Since forum-2014092301. @@ -2253,6 +2284,7 @@ Detail information of a message. creator_username: (string), user_is_ignored: (boolean), # since forum-2015072304 message_create_date: (unix timestamp in seconds), + message_is_liked: (boolean), # since forum-2018112900 message_body: (string), message_body_html: (string), message_body_plain_text: (string), @@ -2292,7 +2324,8 @@ Detail information of a message. delete: (boolean), reply: (boolean), upload_attachment: (boolean), # since forum-2014081801 - report: (uri) # since forum-2015081101 + report: (uri) # since forum-2015081101, + like: (boolean) # since forum-2018112900 } } } @@ -2392,6 +2425,65 @@ Required scopes: * `post` +### GET `/conversation-messages/:messageId/likes` +Get all likes of message. Since forum-2018112900 + +``` +{ + users: [ + { + user_id: (int), + username: (string) + }, + ... + ] +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `read` + +### POST `/conversation-messages/:messageId/likes` +Like a message +``` +{ + status: "ok", + message: "Changes Saved" +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `post` + +### DELETE `/conversation-messages/:messageId/likes` +Unlike a message + +``` +{ + status: "ok", + message: "Changes Saved" +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `post` + + ## Notifications ### GET `/notifications` @@ -2565,6 +2657,316 @@ Required scopes: * `read` +## XenForo Resource Manager + +### GET `/resource-categories` +Get all resource categories + +``` +{ + categories: [ + (category), + ... + ] +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `read` + +### GET `/resource-categories/:categoryId` +Get resource category detail + +``` +{ + category: { + resource_category_id: (int), + category_description: (string), + parent_category_id: (int), + category_resource_count: (int), + category_title: (string), + fields: [ + { + id: (string), + title: (string), + description: (string), + display_group: (string), + position: (string), + choices: [ + { + key: (string), + value: (string), + }, + ... + ], + is_multiple_choice: (boolean), + is_required: (boolean), + }, + ... + ], + links: { + resources: (uri), + resources_in_sub: (uri), + detail: (uri), + permalink: (uri) + }, + permissions: { + add: (bool), + add_file: (bool), + add_url: (bool), + add_price: (bool), + add_no_file_or_url: (bool) + } + } +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `read` + +### GET `/resources` +Get resources + +``` +{ + resources: [ + (resource), + ... + ], + resource_total: (int), + links: { + pages: (int), + next: (uri), + prev: (uri) + } +} +``` + +Parameters: + +* `resource_category_id` (_optional_): Get resources in category +* `resource_category_ids` (_optional_): Get resources in categories. Each category ID separate by comma (,) +* `in_sub` (_optional_): flag to include sub categories in filtering +* `page` (_optional_): page number of resources. +* `limit` (_optional_): number of resources in a page. Default value depends on the system configuration. +* `order` (_optional_): Support `resource_create_date`, `resource_create_date_reverse`, `resource_update_date`, +`resource_update_date_reverse`, `resource_download_count`, `resource_download_count_reverse`, `resource_rating_weighted`, `resource_rating_weighted_reverse` +* `resource_update_date` (_optional_): timestamp to filter +* `resource_ids` (_optional_): resource ids to fetch (ignoring all filters, separated by comma) + +Required scopes: + +* `read` + +### GET `/resources/:resourceId` +Get resource detail + +``` +{ + resource: { + resource_category_id: (int), + creator_user_id: (int), + creator_username: (string), + resource_create_date: (int), + resource_description: (string), + resource_download_count: (int), + resource_id: (int), + resource_rating_count: (int), + resource_rating_sum: (int), + resource_rating_avg: (float), + resource_rating_weighted: (float), + resource_title: (string), + resource_update_date: (int), + resource_attachment_count: (int), + resource_currency: (string), + resource_has_file: (bool), + resource_has_url: (bool), + resource_is_deleted: (bool), + resource_is_followed: (bool), + resource_is_liked: (bool), + resource_is_published: (bool), + resource_like_count: (int), + resource_price: (float), + resource_rating: (int), + resource_tags: (array), + resource_text: (string), + resource_text_html: (string), + resource_text_plain_text: (string), + resource_version: (string), + fields: [ + { + id: (string), + title: (string), + description: (string), + display_group: (string), + position: (string), + choices: [ + { + key: (string), + value: (string), + }, + ... + ], + is_multiple_choice: (boolean), + is_required: (boolean), + value: (string), + values: [ + { + key: (string), + value: (string), + }, + ... + ] + }, + ... + ], + attachments: [ + { + attachment_id: (int), + attachment_download_count: (int), + filename: (string), + attachment_is_inserted: (boolean), + links: { + permalink: (uri), + data: (uri), + thumbnail: (uri) + }, + permissions: { + view: (boolean), + delete: (boolean) + } + }, + ... + ], + links: { + attachments: (uri), + detail: (uri), + followers: (uri), + likes: (uri), + permalink: (uri), + report: (uri), + category: (uri), + content: (uri), + creator_avatar: (uri), + icon: (uri), + ratings: (uri), + thread: (uri) + }, + permissions: { + add_icon: (bool), + download: (bool), + rate: (bool), + delete: (bool), + edit: (bool), + follow: (bool), + like: (bool), + report: (bool), + view: (bool) + } + } +} +``` + +Required scopes: + +* `read` + +### GET `/resources/:resourceId/followers` +Get users following resource + +``` +{ + users: [ + { + user_id: (int), + username: (string), + follow: { + alert: (bool), + email: (bool) + } + }, + ... + ] +} +``` + +Required scopes: + +* `read` + + +### POST `/resources/:resourceId/followers` + +``` +{ + status: "ok", + message: "Changes Saved" +} +``` + +Parameters: + +* `email` (_optional_): whether to receive notification as email + +Required scopes: + +* `post` + +### DELETE `/resources/:resourceId/followers` +Remove follower record + +``` +{ + status: "ok", + message: "Changes Saved" +} +``` + +Parameters: + +* N/A + +Required scopes: + +* `delete` + + +### GET `/resources/followed` +Get resources visitor following + +``` +{ + resources: [ + (resource), + ... + ], + resource_total: (int), + links: { + pages: (int), + next: (uri), + prev: (uri) + } +} +``` + +Parameters: + +* `total` (_optional_): Determine to get `resource_total` only + +Required scopes: + +* `read` + ## Batch requests ### POST `/batch` diff --git a/_files/docs/config.markdown b/_files/docs/config.markdown new file mode 100644 index 00000000..34ae0ea2 --- /dev/null +++ b/_files/docs/config.markdown @@ -0,0 +1,18 @@ +# Configuration Documents + +## config.php + +It's possible to reconfigure some internal api values by adding something like this into `config.php`: + +```php +$config['api'] = [ + 'configKey' => 'configValue', + 'configKey2' => 'someValue', + ... +]; +``` + + * `accessTokenParamKey` default=`oauth_token` + * `apiDirName` default=`api` + * `routerType` default=`XfrocksApi` + * `scopeDelimiter` default=` ` (space) diff --git a/_files/js/Xfrocks/Api/sdk.js b/_files/js/Xfrocks/Api/sdk.js new file mode 100644 index 00000000..82fc3d05 --- /dev/null +++ b/_files/js/Xfrocks/Api/sdk.js @@ -0,0 +1,100 @@ +/* global jQuery */ +/* jshint -W030 */ +// noinspection ThisExpressionReferencesGlobalObjectJS +!function ($, window, document, _undefined) { + 'use strict'; + + window['{prefix}SDK'] = {}; + + var dataUri = '{data_uri}', + requestBaseUri = '{request_uri}', + SDK = window['{prefix}SDK'], + SDK_options = {'client_id': ''}; + + $.extend( + true, + SDK, + { + init: function (options) { + $.extend(true, SDK_options, options); + }, + + isAuthorized: function (scope, callback) { + // callback = function(isAuthorized, apiData) {}; + $.ajax({ + data: { + 'cmd': 'authorized', + 'client_id': SDK_options.client_id, + 'scope': scope + }, + dataType: 'jsonp', + global: false, + success: function (data) { + if (typeof callback !== 'function') { + return; + } + + // noinspection JSUnresolvedVariable + if (data.authorized !== _undefined && data.authorized === 1) { + callback(true, data); + } else { + callback(false, null); + } + }, + url: dataUri + }); + }, + + request: function (route, callback, accessToken, method, data) { + // callback = function(apiData) {}; + var uri = requestBaseUri + '?' + route + '&_xfResponseType=jsonp'; + + var ajaxOptions = { + dataType: 'jsonp', + success: function (data) { + if (typeof callback !== 'function') { + return; + } + + callback(data); + } + }; + + if (method !== _undefined) { + ajaxOptions.type = method; + } else { + ajaxOptions.type = 'GET'; + } + + if (accessToken !== _undefined) { + if (ajaxOptions.type === 'GET') { + uri += '&oauth_token=' + accessToken; + } else { + if (data !== _undefined) { + data.oauth_token = accessToken; + } else { + data = { + oauth_token: accessToken + }; + } + } + } + + if (data !== _undefined) { + ajaxOptions.data = data; + } + + $.ajax( + uri, + ajaxOptions + ); + } + } + ); + + $(document).ready(function () { + if (typeof window['{prefix}Init'] === 'function') { + window['{prefix}Init'](); + } + }); +}(jQuery, this, document); \ No newline at end of file diff --git a/_files/tests/.gitignore b/_files/tests/.gitignore new file mode 100644 index 00000000..57872d0f --- /dev/null +++ b/_files/tests/.gitignore @@ -0,0 +1 @@ +/vendor/ diff --git a/_files/tests/api/IndexTest.php b/_files/tests/api/IndexTest.php new file mode 100644 index 00000000..89bfa98c --- /dev/null +++ b/_files/tests/api/IndexTest.php @@ -0,0 +1,31 @@ +getStatusCode()); + } + + /** + * @return void + */ + public function testReturnsData() + { + $json = static::httpRequestJson('GET', 'index'); + static::assertArrayHasKey('links', $json, 'json has links'); + + static::assertArrayHasKey('system_info', $json); + $systemInfo = $json['system_info']; + static::assertArrayHasKey('oauth/authorize', $systemInfo); + static::assertArrayHasKey('oauth/token', $systemInfo); + } +} diff --git a/_files/tests/api/OAuthTest.php b/_files/tests/api/OAuthTest.php new file mode 100644 index 00000000..5ad6e3e8 --- /dev/null +++ b/_files/tests/api/OAuthTest.php @@ -0,0 +1,86 @@ + 'refresh_token', + 'client_id' => $client['client_id'], + 'client_secret' => $client['client_secret'], + 'refresh_token' => $passwordJson['refresh_token'] + ]); + + static::assertArrayHasKey('access_token', $json); + static::assertArrayHasKey('refresh_token', $json); + } + + /** + * @return void + */ + public function testGrantTypePasswordWithTfa() + { + $client = static::dataApiClient(); + $user = static::dataUser(4); + + $oAuthParams = [ + 'grant_type' => 'password', + 'client_id' => $client['client_id'], + 'client_secret' => $client['client_secret'], + 'username' => $user['username'], + 'password' => $user['password'] + ]; + + static::httpRequestJson('POST', 'oauth/token', [ + 'form_params' => $oAuthParams + ], false); + + $response = static::httpLatestResponse(); + static::assertEquals('202', strval($response->getStatusCode()), '202'); + + static::assertNotEmpty($response->getHeader('X-Api-Tfa-Providers')); + + $oAuthParams['tfa_provider'] = 'totp'; + + $otp = new Otp(); + $oAuthParams['code'] = $otp->totp(Base32::decode($user['tfa_secret'])); + + $json = static::postOauthToken($oAuthParams); + + static::assertArrayHasKey('access_token', $json); + static::assertArrayHasKey('expires_in', $json); + static::assertArrayHasKey('scope', $json); + + static::assertEquals($user['user_id'], $json['user_id']); + } +} diff --git a/_files/tests/api/PostLikeTest.php b/_files/tests/api/PostLikeTest.php new file mode 100644 index 00000000..39419e64 --- /dev/null +++ b/_files/tests/api/PostLikeTest.php @@ -0,0 +1,84 @@ + [ + 'oauth_token' => static::$accessToken, + ] + ] + ); + + $status = static::assertArrayHasKeyPath($json, 'status'); + static::assertEquals('ok', $status); + + $likes = static::httpRequestJson( + 'GET', + sprintf('posts/%d/likes?oauth_token=%s', $post['post_id'], static::$accessToken) + ); + $likedUserId = static::assertArrayHasKeyPath($likes, 'users', 0, 'user_id'); + static::assertEquals(static::$user['user_id'], $likedUserId); + } + + /** + * @return void + */ + public function testDeleteLikes() + { + $post = static::dataPost(); + + $json = static::httpRequestJson( + 'DELETE', + sprintf('posts/%d/likes', $post['post_id']), + [ + 'form_params' => [ + 'oauth_token' => static::$accessToken, + ] + ] + ); + + $status = static::assertArrayHasKeyPath($json, 'status'); + static::assertEquals('ok', $status); + + $likes = static::httpRequestJson( + 'GET', + sprintf('posts/%d/likes?oauth_token=%s', $post['post_id'], static::$accessToken) + ); + $likedUsers = static::assertArrayHasKeyPath($likes, 'users'); + static::assertEquals(0, count($likedUsers)); + } +} diff --git a/_files/tests/api/PostTest.php b/_files/tests/api/PostTest.php new file mode 100644 index 00000000..5e585148 --- /dev/null +++ b/_files/tests/api/PostTest.php @@ -0,0 +1,99 @@ + [ + 'oauth_token' => static::$accessToken, + 'thread_id' => $thread['thread_id'], + 'post_body' => str_repeat(__METHOD__ . ' ', 10) + ] + ] + ); + + static::assertArrayHasKey('post', $json); + } + + /** + * @return void + */ + public function testPutIndex() + { + $post = static::dataPost(); + + $json = static::httpRequestJson( + 'PUT', + 'posts/' . $post['post_id'], + [ + 'form_params' => [ + 'oauth_token' => static::$accessToken, + 'post_body' => str_repeat(__METHOD__ . ' ', 10) + ] + ] + ); + + $jsonPostId = static::assertArrayHasKeyPath($json, 'post', 'post_id'); + static::assertEquals($post['post_id'], $jsonPostId); + } +} diff --git a/_files/tests/api/SubscriptionTest.php b/_files/tests/api/SubscriptionTest.php new file mode 100644 index 00000000..e30c83eb --- /dev/null +++ b/_files/tests/api/SubscriptionTest.php @@ -0,0 +1,83 @@ +postSubscriptions($hubCallback); + + static::assertEquals(202, static::httpLatestResponse()->getStatusCode()); + + $notificationsPath = 'notifications?oauth_token=' . static::$accessToken; + $notificationsJson1 = static::httpRequestJson('GET', $notificationsPath); + static::assertArrayHasKey('subscription_callback', $notificationsJson1); + + $response = static::httpLatestResponse(); + $links = $response->getHeader('Link'); + + $hrefs = []; + foreach ($links as $link) { + if (preg_match('#^<(.+)>; rel=(\w+)$#', $link, $matches) === false) { + continue; + } + $hrefs[$matches[2]] = $matches[1]; + } + static::assertArrayHasKey('hub', $hrefs); + static::assertArrayHasKey('self', $hrefs); + + $this->postSubscriptions($hubCallback, 'unsubscribe'); + static::assertEquals(202, static::httpLatestResponse()->getStatusCode()); + + $notificationJson2 = static::httpRequestJson('GET', $notificationsPath); + static::assertArrayNotHasKey('subscription_callback', $notificationJson2); + } + + /** + * @return void + */ + public function testSubscribeFailure() + { + $hubCallback = static::$apiRoot . 'index.php?tools/websub/echo-none'; + $this->postSubscriptions($hubCallback); + + static::assertEquals(400, static::httpLatestResponse()->getStatusCode()); + } + + /** + * @param string $hubCallback + * @param string $hubMode + * @return mixed|\Psr\Http\Message\ResponseInterface|null + */ + private function postSubscriptions($hubCallback, $hubMode = 'subscribe') + { + $hubCallbackEncoded = rawurlencode($hubCallback); + return static::httpRequest( + 'POST', + "subscriptions?hub.callback={$hubCallbackEncoded}&hub.mode={$hubMode}&hub.topic=user_notification_me&oauth_token=" . static::$accessToken, + [ + 'exceptions' => false, + ] + ); + } +} diff --git a/_files/tests/api/ThreadAttachmentTest.php b/_files/tests/api/ThreadAttachmentTest.php new file mode 100644 index 00000000..2e4c2bc2 --- /dev/null +++ b/_files/tests/api/ThreadAttachmentTest.php @@ -0,0 +1,147 @@ +postThreads(__METHOD__); + $threadId = static::assertArrayHasKeyPath($thread, 'thread_id'); + + $hash = strval(microtime(true)); + $attachment = $this->postThreadsAttachments($hash); + $attachmentId = static::assertArrayHasKeyPath($attachment, 'attachment_id'); + + $json = static::httpRequestJson( + 'PUT', + "threads/{$threadId}", + [ + 'form_params' => [ + 'attachment_hash' => $hash, + 'oauth_token' => static::$accessTokenBypassFloodCheck, + 'post_body' => __METHOD__ . ' now with attachment', + ], + ] + ); + $thread = static::assertArrayHasKeyPath($json, 'thread'); + $jsonThreadId = static::assertArrayHasKeyPath($thread, 'thread_id'); + static::assertEquals($threadId, $jsonThreadId); + $postAttachments = static::assertArrayHasKeyPath($thread, 'first_post', 'attachments'); + + $attachmentFound = false; + foreach ($postAttachments as $postAttachment) { + $postAttachmentId = static::assertArrayHasKeyPath($postAttachment, 'attachment_id'); + if ($postAttachmentId === $attachmentId) { + $attachmentFound = true; + } + } + static::assertTrue($attachmentFound); + } + + /** + * @return void + */ + public function testDeleteNewlyUploadedAttachment() + { + $attachment = $this->postThreadsAttachments(); + $dataLink = static::assertArrayHasKeyPath($attachment, 'links', 'data'); + + $json2 = static::httpRequestJson('DELETE', $dataLink); + $status = static::assertArrayHasKeyPath($json2, 'status'); + static::assertEquals('ok', $status); + } + + /** + * @return void + */ + public function testDeleteAssociatedAttachment() + { + $hash = strval(microtime(true)); + $attachment = $this->postThreadsAttachments($hash); + + $thread = $this->postThreads(__METHOD__, $hash); + $threadAttachments = static::assertArrayHasKeyPath($thread, 'first_post', 'attachments'); + static::assertCount(1, $threadAttachments); + $threadAttachment = reset($threadAttachments); + static::assertEquals( + $attachment['attachment_id'], + static::assertArrayHasKeyPath($threadAttachment, 'attachment_id') + ); + $dataLink = static::assertArrayHasKeyPath($threadAttachment, 'links', 'data'); + + $json2 = static::httpRequestJson('DELETE', $dataLink); + $status = static::assertArrayHasKeyPath($json2, 'status'); + static::assertEquals('ok', $status); + } + + /** + * @param string $method + * @param string $hash + * @return mixed + */ + private function postThreads($method, $hash = '') + { + $forum = static::dataForum(); + $json = static::httpRequestJson( + 'POST', + 'threads', + [ + 'form_params' => [ + 'attachment_hash' => $hash, + 'forum_id' => $forum['node_id'], + 'oauth_token' => static::$accessTokenBypassFloodCheck, + 'post_body' => $method, + 'thread_title' => $method, + ], + ] + ); + + return static::assertArrayHasKeyPath($json, 'thread'); + } + + /** + * @param string $hash + * @return mixed + */ + private function postThreadsAttachments($hash = '') + { + $accessToken = static::$accessTokenBypassFloodCheck; + $fileName = 'white.png'; + $forum = static::dataForum(); + + $json = static::httpRequestJson( + 'POST', + "threads/attachments?attachment_hash={$hash}&forum_id={$forum['node_id']}&oauth_token={$accessToken}", + [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen(__DIR__ . "/files/{$fileName}", 'r'), + 'filename' => $fileName, + ], + ] + ] + ); + + return static::assertArrayHasKeyPath($json, 'attachment'); + } +} diff --git a/_files/tests/api/ThreadTest.php b/_files/tests/api/ThreadTest.php new file mode 100644 index 00000000..5039b2e3 --- /dev/null +++ b/_files/tests/api/ThreadTest.php @@ -0,0 +1,124 @@ + [ + 'forum_id' => $forum['node_id'], + 'oauth_token' => static::$accessTokenBypassFloodCheck, + 'post_body' => str_repeat(__METHOD__ . ' ', 10), + 'thread_title' => __METHOD__, + 'thread_tags' => $randomTag + ], + ] + ); + + static::assertArrayHasKey('thread', $json); + + static::assertArrayHasKeyPath($json, 'thread', 'thread_tags'); + static::assertNotEmpty($json['thread']['thread_tags']); + + $tagIds = array_keys($json['thread']['thread_tags']); + $tagJson = static::httpRequestJson( + 'GET', + "tags/${tagIds[0]}" + ); + + static::assertArrayHasKey('tag', $tagJson); + } + + /** + * @return void + */ + public function testPutIndex() + { + $thread = static::dataThread(); + $token = $this::postPassword(static::dataApiClient(), static::dataUser()); + + $json = static::httpRequestJson( + 'PUT', + 'threads/' . $thread['thread_id'], + [ + 'form_params' => [ + 'post_body' => str_repeat(__METHOD__ . ' ', 10), + 'oauth_token' => $token['access_token'] + ] + ] + ); + + $jsonThreadId = static::assertArrayHasKeyPath($json, 'thread', 'thread_id'); + static::assertEquals($thread['thread_id'], $jsonThreadId); + } + + /** @noinspection PhpUnusedPrivateMethodInspection */ + /** + * @return void + * @see ThreadAttachmentTest::postThreadsAttachments() + */ + private function _testPostAttachments() + { + // intentionally left blank + } +} diff --git a/_files/tests/api/UserTest.php b/_files/tests/api/UserTest.php new file mode 100644 index 00000000..1abc4c83 --- /dev/null +++ b/_files/tests/api/UserTest.php @@ -0,0 +1,110 @@ + [ + 'user_email' => $userEmail, + 'username' => $username, + 'password' => '123456', + 'oauth_token' => static::$accessToken + ] + ] + ); + + static::assertArrayHasKey('user', $json); + static::assertArrayHasKey('token', $json); + + $token = $json['token']; + static::assertArrayHasKey('access_token', $token); + static::assertArrayHasKey('expires_in', $token); + static::assertArrayHasKey('scope', $token); + static::assertArrayHasKey('refresh_token', $token); + } + + /** + * @return void + */ + public function testPutIndex() + { + $user = static::dataUser(); + + $json = static::httpRequestJson( + 'PUT', + 'users/' . $user['user_id'], + [ + 'form_params' => [ + 'oauth_token' => static::$accessToken + ] + ] + ); + + static::assertArrayHasKey('status', $json); + static::assertEquals('ok', $json['status']); + } +} diff --git a/_files/tests/api/ZzzTest.php b/_files/tests/api/ZzzTest.php new file mode 100644 index 00000000..940c337a --- /dev/null +++ b/_files/tests/api/ZzzTest.php @@ -0,0 +1,118 @@ + [ + [ + 'name' => 'file', + 'contents' => fopen(__DIR__ . "/files/{$fileName}", 'r'), + 'filename' => $fileName, + ], + ] + ] + ); + + static::assertEquals(200, static::httpLatestResponse()->getStatusCode()); + } + + /** + * @return void + */ + public function testPostMultipartWithBodyParams() + { + $fileName = 'white.png'; + $forum = static::dataForum(); + + static::httpRequest( + 'POST', + 'threads/attachments', + [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen(__DIR__ . "/files/{$fileName}", 'r'), + 'filename' => $fileName, + ], + [ + 'name' => 'forum_id', + 'contents' => $forum['node_id'], + ], + [ + 'name' => 'oauth_token', + 'contents' => static::$accessToken, + ], + ] + ] + ); + + static::assertEquals(200, static::httpLatestResponse()->getStatusCode()); + } + + /** + * @return void + */ + public function testOttWithGuest() + { + $this->requestActiveOttToken(0); + } + + /** + * @return void + */ + public function testOttWithUser() + { + $user = static::dataUser(); + $this->requestActiveOttToken($user['user_id']); + } + + /** + * @param int $userId + * @return void + */ + protected function requestActiveOttToken($userId) + { + $timestamp = time() + 5 * 30; + $accessToken = ($userId > 0) ? static::$accessToken : ''; + $client = static::dataApiClient(); + $forum = static::dataForum(); + + $once = md5($userId . $timestamp . $accessToken . $client['client_secret']); + $ott = sprintf('%d,%d,%s,%s', $userId, $timestamp, $once, $client['client_id']); + + + $forumId = ($userId > 0) ? $forum['node_id'] : 0; + $json = static::httpRequestJson('GET', "threads?oauth_token={$ott}&forum_id={$forumId}"); + + static::assertArrayHasKey('threads', $json); + } +} diff --git a/_files/tests/api/files/white.png b/_files/tests/api/files/white.png new file mode 100644 index 00000000..a5eb5c67 Binary files /dev/null and b/_files/tests/api/files/white.png differ diff --git a/_files/tests/autoload.php b/_files/tests/autoload.php new file mode 100644 index 00000000..a9e4fceb --- /dev/null +++ b/_files/tests/autoload.php @@ -0,0 +1,12 @@ +addPsr4('tests\api\\', __DIR__ . '/api'); +$loader->addPsr4('tests\bases\\', __DIR__ . '/bases'); + +return $loader; diff --git a/_files/tests/bases/ApiTestCase.php b/_files/tests/bases/ApiTestCase.php new file mode 100644 index 00000000..365d809b --- /dev/null +++ b/_files/tests/bases/ApiTestCase.php @@ -0,0 +1,240 @@ + self::$apiRoot, + ]); + } + + /** + * @return \Psr\Http\Message\ResponseInterface + */ + protected static function httpLatestResponse() + { + /** @var \Psr\Http\Message\ResponseInterface $response */ + $response = self::$latestResponse; + static::assertNotNull($response); + + return $response; + } + + /** + * @param string $method + * @param string $path + * @param array $options + * @return mixed|\Psr\Http\Message\ResponseInterface|null + */ + protected static function httpRequest($method, $path, array $options = []) + { + if (preg_match('#^https?://#', $path) === 1) { + $uri = $path; + } else { + $uri = 'index.php?' . str_replace('?', '&', $path); + } + + if (self::debugMode()) { + echo(str_repeat('----', 10) . "\n"); + var_dump($method, $uri, $options); + } + + try { + self::$latestResponse = self::$http->request($method, $uri, $options); + } catch (GuzzleException $e) { + throw new \RuntimeException('', 0, $e); + } + + if (self::debugMode()) { + var_dump(self::$latestResponse->getStatusCode()); + echo(str_repeat('----', 10) . "\n\n"); + } + + return self::$latestResponse; + } + + /** + * @param string $method + * @param string $path + * @param array $options + * @param bool $checkError + * @return array + */ + protected static function httpRequestJson($method, $path, array $options = [], $checkError = true) + { + $response = static::httpRequest($method, $path, $options); + + $contentType = $response->getHeaders()['Content-Type'][0]; + static::assertContains('application/json', $contentType); + + $json = json_decode(strval($response->getBody()), true); + static::assertTrue(is_array($json)); + + if ($checkError) { + foreach (['error', 'error_description', 'errors'] as $errorKey) { + $errorMessage = "{$errorKey}: " . var_export(isset($json[$errorKey]) ? $json[$errorKey] : '', true); + static::assertArrayNotHasKey($errorKey, $json, $errorMessage); + } + } + + return $json; + } + + /** + * @param string|int ...$keys + * @return mixed + */ + protected static function data(...$keys) + { + if (!is_array(self::$testData)) { + self::$testData = []; + + $path = '/tmp/api_test.json'; + static::assertFileExists($path); + + $contents = file_get_contents($path); + $json = is_string($contents) ? json_decode($contents, true) : false; + static::assertTrue(is_array($json)); + + self::$testData = $json; + } + + $data = self::$testData; + foreach ($keys as $key) { + static::assertArrayHasKey($key, $data); + $data = $data[$key]; + } + + return $data; + } + + /** + * @return array + */ + protected static function dataApiClient() + { + return static::data('apiClient'); + } + + /** + * @return array + */ + protected static function dataForum() + { + return static::data('forum'); + } + + /** + * @param int $i + * @return array + */ + protected static function dataUser($i = 0) + { + return static::data('users', $i); + } + + /** + * @return array + */ + protected static function dataUserWithBypassFloodCheckPermission() + { + return static::dataUser(3); + } + + /** + * @param int $i + * @return array + */ + protected static function dataThread($i = 0) + { + return static::data('threads', $i); + } + + /** + * @param int $i + * @return array + */ + protected static function dataPost($i = 0) + { + return static::data('posts', $i); + } + + /** + * @return bool + */ + protected static function debugMode() + { + return (getenv('API_TEST_CASE_DEBUG') === '1'); + } + + /** + * @param array $client + * @param array $user + * @return array + */ + protected static function postPassword(array $client, array $user) + { + return static::postOauthToken([ + 'grant_type' => 'password', + 'client_id' => $client['client_id'], + 'client_secret' => $client['client_secret'], + 'username' => $user['username'], + 'password' => $user['password'] + ]); + } + + /** + * @param array $params + * @return array + */ + protected static function postOauthToken(array $params) + { + return static::httpRequestJson('POST', 'oauth/token', ['form_params' => $params]); + } + + /** + * @param array $data + * @param mixed ...$keys + * @return mixed + */ + protected static function assertArrayHasKeyPath(array $data, ...$keys) + { + $ref =& $data; + foreach ($keys as $key) { + self::assertTrue(is_array($ref), var_export($ref, true) . ' is not an array'); + self::assertArrayHasKey($key, $ref, "Key '{$key}' not found: " . implode(', ', array_keys($ref))); + $ref =& $ref[$key]; + } + + return $ref; + } +} diff --git a/_files/tests/composer.json b/_files/tests/composer.json new file mode 100644 index 00000000..32bb12e6 --- /dev/null +++ b/_files/tests/composer.json @@ -0,0 +1,8 @@ +{ + "require-dev": { + "phpunit/phpunit": "^7.3" + }, + "scripts": { + "test": "./vendor/bin/phpunit --testdox" + } +} diff --git a/_files/tests/composer.lock b/_files/tests/composer.lock new file mode 100644 index 00000000..da85c8a2 --- /dev/null +++ b/_files/tests/composer.lock @@ -0,0 +1,1426 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "df297bfc0e15dbb78ca294c94bbd143e", + "packages": [], + "packages-dev": [ + { + "name": "doctrine/instantiator", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "reference": "185b8868aa9bf7159f5f953ed5afb2d7fcdc3bda", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "ext-pdo": "*", + "ext-phar": "*", + "phpunit/phpunit": "^6.2.3", + "squizlabs/php_codesniffer": "^3.0.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "http://ocramius.github.com/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://github.com/doctrine/instantiator", + "keywords": [ + "constructor", + "instantiate" + ], + "time": "2017-07-22T11:58:36+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.8.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "reference": "3e01bdad3e18354c3dce54466b7fbe33a9f9f7f8", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "replace": { + "myclabs/deep-copy": "self.version" + }, + "require-dev": { + "doctrine/collections": "^1.0", + "doctrine/common": "^2.6", + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + }, + "files": [ + "src/DeepCopy/deep_copy.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "time": "2018-06-11T23:09:50+00:00" + }, + { + "name": "phar-io/manifest", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "reference": "7761fcacf03b4d4f16e7ccb606d4879ca431fcf4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-phar": "*", + "phar-io/version": "^2.0", + "php": "^5.6 || ^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "time": "2018-07-08T19:23:20+00:00" + }, + { + "name": "phar-io/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "reference": "45a2ec53a73c70ce41d55cedef9063630abaf1b6", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "time": "2018-07-08T19:19:57+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", + "reference": "21bdeb5f65d7ebf9f43b1b25d404f87deab5bfb6", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "time": "2017-09-11T18:02:19+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "4.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "94fd0001232e47129dd3504189fa1c7225010d08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/94fd0001232e47129dd3504189fa1c7225010d08", + "reference": "94fd0001232e47129dd3504189fa1c7225010d08", + "shasum": "" + }, + "require": { + "php": "^7.0", + "phpdocumentor/reflection-common": "^1.0.0", + "phpdocumentor/type-resolver": "^0.4.0", + "webmozart/assert": "^1.0" + }, + "require-dev": { + "doctrine/instantiator": "~1.0.5", + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "time": "2017-11-30T07:14:17+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "0.4.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/9c977708995954784726e25d0cd1dddf4e65b0f7", + "reference": "9c977708995954784726e25d0cd1dddf4e65b0f7", + "shasum": "" + }, + "require": { + "php": "^5.5 || ^7.0", + "phpdocumentor/reflection-common": "^1.0" + }, + "require-dev": { + "mockery/mockery": "^0.9.4", + "phpunit/phpunit": "^5.2||^4.8.24" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "time": "2017-07-14T14:27:02+00:00" + }, + { + "name": "phpspec/prophecy", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "reference": "4ba436b55987b4bf311cb7c6ba82aa528aac0a06", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": "^5.3|^7.0", + "phpdocumentor/reflection-docblock": "^2.0|^3.0.2|^4.0", + "sebastian/comparator": "^1.1|^2.0|^3.0", + "sebastian/recursion-context": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "phpspec/phpspec": "^2.5|^3.2", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.5 || ^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.8.x-dev" + } + }, + "autoload": { + "psr-0": { + "Prophecy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "time": "2018-08-05T17:53:17+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "6.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "865662550c384bc1db7e51d29aeda1c2c161d69a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/865662550c384bc1db7e51d29aeda1c2c161d69a", + "reference": "865662550c384bc1db7e51d29aeda1c2c161d69a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlwriter": "*", + "php": "^7.1", + "phpunit/php-file-iterator": "^2.0", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-token-stream": "^3.0", + "sebastian/code-unit-reverse-lookup": "^1.0.1", + "sebastian/environment": "^3.1", + "sebastian/version": "^2.0.1", + "theseer/tokenizer": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "suggest": { + "ext-xdebug": "^2.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "time": "2018-06-01T07:51:50+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "050bedf145a257b1ff02746c31894800e5122946" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/050bedf145a257b1ff02746c31894800e5122946", + "reference": "050bedf145a257b1ff02746c31894800e5122946", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "time": "2018-09-13T20:33:42+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "time": "2015-06-21T13:50:34+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/8b8454ea6958c3dee38453d3bd571e023108c91f", + "reference": "8b8454ea6958c3dee38453d3bd571e023108c91f", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "time": "2018-02-01T13:07:23+00:00" + }, + { + "name": "phpunit/php-token-stream", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "21ad88bbba7c3d93530d93994e0a33cd45f02ace" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/21ad88bbba7c3d93530d93994e0a33cd45f02ace", + "reference": "21ad88bbba7c3d93530d93994e0a33cd45f02ace", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "time": "2018-02-01T13:16:43+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "7.3.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "7b331efabbb628c518c408fdfcaf571156775de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/7b331efabbb628c518c408fdfcaf571156775de2", + "reference": "7b331efabbb628c518c408fdfcaf571156775de2", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.1", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "myclabs/deep-copy": "^1.7", + "phar-io/manifest": "^1.0.2", + "phar-io/version": "^2.0", + "php": "^7.1", + "phpspec/prophecy": "^1.7", + "phpunit/php-code-coverage": "^6.0.7", + "phpunit/php-file-iterator": "^2.0.1", + "phpunit/php-text-template": "^1.2.1", + "phpunit/php-timer": "^2.0", + "sebastian/comparator": "^3.0", + "sebastian/diff": "^3.0", + "sebastian/environment": "^3.1", + "sebastian/exporter": "^3.1", + "sebastian/global-state": "^2.0", + "sebastian/object-enumerator": "^3.0.3", + "sebastian/resource-operations": "^1.0", + "sebastian/version": "^2.0.1" + }, + "conflict": { + "phpunit/phpunit-mock-objects": "*" + }, + "require-dev": { + "ext-pdo": "*" + }, + "suggest": { + "ext-soap": "*", + "ext-xdebug": "*", + "phpunit/php-invoker": "^2.0" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "time": "2018-09-08T15:14:29+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", + "reference": "4419fcdb5eabb9caa61a27c7a1db532a6b55dd18", + "shasum": "" + }, + "require": { + "php": "^5.6 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.7 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "time": "2017-03-04T06:30:41+00:00" + }, + { + "name": "sebastian/comparator", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "reference": "5de4fc177adf9bce8df98d8d141a7559d7ccf6da", + "shasum": "" + }, + "require": { + "php": "^7.1", + "sebastian/diff": "^3.0", + "sebastian/exporter": "^3.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "time": "2018-07-12T15:12:46+00:00" + }, + { + "name": "sebastian/diff", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "366541b989927187c4ca70490a35615d3fef2dce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/366541b989927187c4ca70490a35615d3fef2dce", + "reference": "366541b989927187c4ca70490a35615d3fef2dce", + "shasum": "" + }, + "require": { + "php": "^7.1" + }, + "require-dev": { + "phpunit/phpunit": "^7.0", + "symfony/process": "^2 || ^3.3 || ^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "time": "2018-06-10T07:54:39+00:00" + }, + { + "name": "sebastian/environment", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "reference": "cd0871b3975fb7fc44d11314fd1ee20925fce4f5", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "time": "2017-07-01T08:51:00+00:00" + }, + { + "name": "sebastian/exporter", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "234199f4528de6d12aaa58b612e98f7d36adb937" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/234199f4528de6d12aaa58b612e98f7d36adb937", + "reference": "234199f4528de6d12aaa58b612e98f7d36adb937", + "shasum": "" + }, + "require": { + "php": "^7.0", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "ext-mbstring": "*", + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "time": "2017-04-03T13:19:02+00:00" + }, + { + "name": "sebastian/global-state", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "reference": "e8ba02eed7bbbb9e59e43dedd3dddeff4a56b0c4", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "time": "2017-04-27T15:39:26+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/7cfd9e65d11ffb5af41198476395774d4c8a84c5", + "reference": "7cfd9e65d11ffb5af41198476395774d4c8a84c5", + "shasum": "" + }, + "require": { + "php": "^7.0", + "sebastian/object-reflector": "^1.1.1", + "sebastian/recursion-context": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "time": "2017-08-03T12:35:26+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "773f97c67f28de00d397be301821b06708fca0be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/773f97c67f28de00d397be301821b06708fca0be", + "reference": "773f97c67f28de00d397be301821b06708fca0be", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "time": "2017-03-29T09:07:27+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", + "reference": "5b0cd723502bac3b006cbf3dbf7a1e3fcefe4fa8", + "shasum": "" + }, + "require": { + "php": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "time": "2017-03-03T06:23:57+00:00" + }, + { + "name": "sebastian/resource-operations", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "time": "2015-07-28T20:34:47+00:00" + }, + { + "name": "sebastian/version", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/99732be0ddb3361e16ad77b68ba41efc8e979019", + "reference": "99732be0ddb3361e16ad77b68ba41efc8e979019", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "time": "2016-10-03T07:35:21+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/cb2f008f3f05af2893a87208fe6a6c4985483f8b", + "reference": "cb2f008f3f05af2893a87208fe6a6c4985483f8b", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "time": "2017-04-07T12:08:54+00:00" + }, + { + "name": "webmozart/assert", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/webmozart/assert.git", + "reference": "0df1908962e7a3071564e857d86874dad1ef204a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozart/assert/zipball/0df1908962e7a3071564e857d86874dad1ef204a", + "reference": "0df1908962e7a3071564e857d86874dad1ef204a", + "shasum": "" + }, + "require": { + "php": "^5.3.3 || ^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.6", + "sebastian/version": "^1.0.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "time": "2018-01-29T19:49:41+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/_files/tests/phpunit.sh b/_files/tests/phpunit.sh new file mode 100755 index 00000000..71838b7f --- /dev/null +++ b/_files/tests/phpunit.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec ./vendor/bin/phpunit "$@" diff --git a/_files/tests/phpunit.xml b/_files/tests/phpunit.xml new file mode 100644 index 00000000..19340771 --- /dev/null +++ b/_files/tests/phpunit.xml @@ -0,0 +1,8 @@ + + + + + api + + + diff --git a/_output/admin_navigation/XfrocksApi.json b/_output/admin_navigation/XfrocksApi.json new file mode 100644 index 00000000..95fd7d84 --- /dev/null +++ b/_output/admin_navigation/XfrocksApi.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "", + "display_order": 201, + "link": "", + "icon": "fa-plane", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiAuthCodes.json b/_output/admin_navigation/XfrocksApiAuthCodes.json new file mode 100644 index 00000000..4b77c28d --- /dev/null +++ b/_output/admin_navigation/XfrocksApiAuthCodes.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 30, + "link": "api-auth-codes", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiClients.json b/_output/admin_navigation/XfrocksApiClients.json new file mode 100644 index 00000000..da4c9886 --- /dev/null +++ b/_output/admin_navigation/XfrocksApiClients.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 10, + "link": "api-clients", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiLogs.json b/_output/admin_navigation/XfrocksApiLogs.json new file mode 100644 index 00000000..91d98ed7 --- /dev/null +++ b/_output/admin_navigation/XfrocksApiLogs.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 9999, + "link": "api-logs", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiRefreshTokens.json b/_output/admin_navigation/XfrocksApiRefreshTokens.json new file mode 100644 index 00000000..a9a3f47e --- /dev/null +++ b/_output/admin_navigation/XfrocksApiRefreshTokens.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 40, + "link": "api-refresh-tokens", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiSubscriptions.json b/_output/admin_navigation/XfrocksApiSubscriptions.json new file mode 100644 index 00000000..58bc7ce0 --- /dev/null +++ b/_output/admin_navigation/XfrocksApiSubscriptions.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 5000, + "link": "api-subscriptions", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/XfrocksApiTokens.json b/_output/admin_navigation/XfrocksApiTokens.json new file mode 100644 index 00000000..cd5a88c7 --- /dev/null +++ b/_output/admin_navigation/XfrocksApiTokens.json @@ -0,0 +1,10 @@ +{ + "parent_navigation_id": "XfrocksApi", + "display_order": 20, + "link": "api-tokens", + "icon": "", + "admin_permission_id": "bdApi", + "debug_only": false, + "development_only": false, + "hide_no_children": false +} \ No newline at end of file diff --git a/_output/admin_navigation/_metadata.json b/_output/admin_navigation/_metadata.json new file mode 100644 index 00000000..f31d1a79 --- /dev/null +++ b/_output/admin_navigation/_metadata.json @@ -0,0 +1,23 @@ +{ + "XfrocksApi.json": { + "hash": "70c9a12340688ed97c2c94750b2bfe4a" + }, + "XfrocksApiAuthCodes.json": { + "hash": "36dfe726932ff2ff3f37966ab8004ce2" + }, + "XfrocksApiClients.json": { + "hash": "f0a9f46b160ed14cd6e3b8bc55bb1ce1" + }, + "XfrocksApiLogs.json": { + "hash": "538a3eed705ea8a9e44bb39610deef33" + }, + "XfrocksApiRefreshTokens.json": { + "hash": "70f7b7990f1ae8bdaaf5033aaab50e14" + }, + "XfrocksApiSubscriptions.json": { + "hash": "7266e7d7343935a993e1a0e421740ae0" + }, + "XfrocksApiTokens.json": { + "hash": "6d8bdd9c9ae05e5090a33674b4ed8528" + } +} \ No newline at end of file diff --git a/_output/admin_permissions/_metadata.json b/_output/admin_permissions/_metadata.json new file mode 100644 index 00000000..d0ddbfbc --- /dev/null +++ b/_output/admin_permissions/_metadata.json @@ -0,0 +1,5 @@ +{ + "bdApi.json": { + "hash": "f25cbbe23fb758887568117cccb8471e" + } +} \ No newline at end of file diff --git a/_output/admin_permissions/bdApi.json b/_output/admin_permissions/bdApi.json new file mode 100644 index 00000000..7b4a8363 --- /dev/null +++ b/_output/admin_permissions/bdApi.json @@ -0,0 +1,3 @@ +{ + "display_order": 9999 +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Entity-Post_Xfrocks-Api-XF-Entity-Post.json b/_output/class_extensions/XF-Entity-Post_Xfrocks-Api-XF-Entity-Post.json new file mode 100644 index 00000000..72a82a6c --- /dev/null +++ b/_output/class_extensions/XF-Entity-Post_Xfrocks-Api-XF-Entity-Post.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Entity\\Post", + "to_class": "Xfrocks\\Api\\XF\\Entity\\Post", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Entity-Thread_Xfrocks-Api-XF-Entity-Thread.json b/_output/class_extensions/XF-Entity-Thread_Xfrocks-Api-XF-Entity-Thread.json new file mode 100644 index 00000000..e0eb1ac8 --- /dev/null +++ b/_output/class_extensions/XF-Entity-Thread_Xfrocks-Api-XF-Entity-Thread.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Entity\\Thread", + "to_class": "Xfrocks\\Api\\XF\\Entity\\Thread", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Entity-UserAlert_Xfrocks-Api-XF-Entity-UserAlert.json b/_output/class_extensions/XF-Entity-UserAlert_Xfrocks-Api-XF-Entity-UserAlert.json new file mode 100644 index 00000000..51b10efe --- /dev/null +++ b/_output/class_extensions/XF-Entity-UserAlert_Xfrocks-Api-XF-Entity-UserAlert.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Entity\\UserAlert", + "to_class": "Xfrocks\\Api\\XF\\Entity\\UserAlert", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Entity-UserOption_Xfrocks-Api-XF-Entity-UserOption.json b/_output/class_extensions/XF-Entity-UserOption_Xfrocks-Api-XF-Entity-UserOption.json new file mode 100644 index 00000000..40788ab9 --- /dev/null +++ b/_output/class_extensions/XF-Entity-UserOption_Xfrocks-Api-XF-Entity-UserOption.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Entity\\UserOption", + "to_class": "Xfrocks\\Api\\XF\\Entity\\UserOption", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Entity-User_Xfrocks-Api-XF-Entity-User.json b/_output/class_extensions/XF-Entity-User_Xfrocks-Api-XF-Entity-User.json new file mode 100644 index 00000000..78d034fa --- /dev/null +++ b/_output/class_extensions/XF-Entity-User_Xfrocks-Api-XF-Entity-User.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Entity\\User", + "to_class": "Xfrocks\\Api\\XF\\Entity\\User", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Pub-Controller-Account_Xfrocks-Api-XF-Pub-Controller-Account.json b/_output/class_extensions/XF-Pub-Controller-Account_Xfrocks-Api-XF-Pub-Controller-Account.json new file mode 100644 index 00000000..be8f8200 --- /dev/null +++ b/_output/class_extensions/XF-Pub-Controller-Account_Xfrocks-Api-XF-Pub-Controller-Account.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Pub\\Controller\\Account", + "to_class": "Xfrocks\\Api\\XF\\Pub\\Controller\\Account", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Pub-Controller-Misc_Xfrocks-Api-XF-Pub-Controller-Misc.json b/_output/class_extensions/XF-Pub-Controller-Misc_Xfrocks-Api-XF-Pub-Controller-Misc.json new file mode 100644 index 00000000..cbcf06ce --- /dev/null +++ b/_output/class_extensions/XF-Pub-Controller-Misc_Xfrocks-Api-XF-Pub-Controller-Misc.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Pub\\Controller\\Misc", + "to_class": "Xfrocks\\Api\\XF\\Pub\\Controller\\Misc", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Repository-Node_Xfrocks-Api-XF-Repository-Node.json b/_output/class_extensions/XF-Repository-Node_Xfrocks-Api-XF-Repository-Node.json new file mode 100644 index 00000000..ce6366e3 --- /dev/null +++ b/_output/class_extensions/XF-Repository-Node_Xfrocks-Api-XF-Repository-Node.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Repository\\Node", + "to_class": "Xfrocks\\Api\\XF\\Repository\\Node", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Service-AddOn-HashGenerator_Xfrocks-Api-XF-Service-AddOn-HashGenerator.json b/_output/class_extensions/XF-Service-AddOn-HashGenerator_Xfrocks-Api-XF-Service-AddOn-HashGenerator.json new file mode 100644 index 00000000..e132a740 --- /dev/null +++ b/_output/class_extensions/XF-Service-AddOn-HashGenerator_Xfrocks-Api-XF-Service-AddOn-HashGenerator.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Service\\AddOn\\HashGenerator", + "to_class": "Xfrocks\\Api\\XF\\Service\\AddOn\\HashGenerator", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Service-Conversation-Notifier_Xfrocks-Api-XF-Service-Conversation-Notifier.json b/_output/class_extensions/XF-Service-Conversation-Notifier_Xfrocks-Api-XF-Service-Conversation-Notifier.json new file mode 100644 index 00000000..f9c8fc2f --- /dev/null +++ b/_output/class_extensions/XF-Service-Conversation-Notifier_Xfrocks-Api-XF-Service-Conversation-Notifier.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Service\\Conversation\\Notifier", + "to_class": "Xfrocks\\Api\\XF\\Service\\Conversation\\Notifier", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/XF-Service-User-DeleteCleanUp_Xfrocks-Api-XF-Service-User-DeleteCleanUp.json b/_output/class_extensions/XF-Service-User-DeleteCleanUp_Xfrocks-Api-XF-Service-User-DeleteCleanUp.json new file mode 100644 index 00000000..471c3917 --- /dev/null +++ b/_output/class_extensions/XF-Service-User-DeleteCleanUp_Xfrocks-Api-XF-Service-User-DeleteCleanUp.json @@ -0,0 +1,6 @@ +{ + "from_class": "XF\\Service\\User\\DeleteCleanUp", + "to_class": "Xfrocks\\Api\\XF\\Service\\User\\DeleteCleanUp", + "execute_order": 10, + "active": true +} \ No newline at end of file diff --git a/_output/class_extensions/_metadata.json b/_output/class_extensions/_metadata.json new file mode 100644 index 00000000..4ca35c1d --- /dev/null +++ b/_output/class_extensions/_metadata.json @@ -0,0 +1,35 @@ +{ + "XF-Entity-Post_Xfrocks-Api-XF-Entity-Post.json": { + "hash": "afa2f2b4f50ac4d3ddb40574f4e4b1f4" + }, + "XF-Entity-Thread_Xfrocks-Api-XF-Entity-Thread.json": { + "hash": "014ff641979958ef7027fd26ac786bea" + }, + "XF-Entity-UserAlert_Xfrocks-Api-XF-Entity-UserAlert.json": { + "hash": "af90a9fe08b2b303408776c3c7a27301" + }, + "XF-Entity-UserOption_Xfrocks-Api-XF-Entity-UserOption.json": { + "hash": "43dda0de74c45389e5909291d632637a" + }, + "XF-Entity-User_Xfrocks-Api-XF-Entity-User.json": { + "hash": "ae48a23e0edac88b42ddfba1c29cea4d" + }, + "XF-Pub-Controller-Account_Xfrocks-Api-XF-Pub-Controller-Account.json": { + "hash": "a0f1bad1fd472ab41ecf0ec219dd5d3d" + }, + "XF-Pub-Controller-Misc_Xfrocks-Api-XF-Pub-Controller-Misc.json": { + "hash": "3c8843fcf5b4cc24e709fa61fc308c09" + }, + "XF-Repository-Node_Xfrocks-Api-XF-Repository-Node.json": { + "hash": "06e16772e3cf6d06e667820c07c87fd6" + }, + "XF-Service-AddOn-HashGenerator_Xfrocks-Api-XF-Service-AddOn-HashGenerator.json": { + "hash": "8b889b931b2efb5470e8a5adb2c5d7d9" + }, + "XF-Service-Conversation-Notifier_Xfrocks-Api-XF-Service-Conversation-Notifier.json": { + "hash": "da13a532d2404806c8685a15313b5a22" + }, + "XF-Service-User-DeleteCleanUp_Xfrocks-Api-XF-Service-User-DeleteCleanUp.json": { + "hash": "0a078c2e5fd8b7902570609f74e1485c" + } +} \ No newline at end of file diff --git a/_output/code_event_listeners/_metadata.json b/_output/code_event_listeners/_metadata.json new file mode 100644 index 00000000..73d7a724 --- /dev/null +++ b/_output/code_event_listeners/_metadata.json @@ -0,0 +1,8 @@ +{ + "app_setup_a0014361bc61db6e914661e27a42c445.json": { + "hash": "52302ecadc637a759ff80d2b531de053" + }, + "user_content_change_init_83289233a941c54c085f3fd42a6474a3.json": { + "hash": "4ac0bd8ce858bdcb0f1a900427501faf" + } +} \ No newline at end of file diff --git a/_output/code_event_listeners/app_setup_a0014361bc61db6e914661e27a42c445.json b/_output/code_event_listeners/app_setup_a0014361bc61db6e914661e27a42c445.json new file mode 100644 index 00000000..457b78bb --- /dev/null +++ b/_output/code_event_listeners/app_setup_a0014361bc61db6e914661e27a42c445.json @@ -0,0 +1,9 @@ +{ + "event_id": "app_setup", + "execute_order": 10, + "callback_class": "Xfrocks\\Api\\Listener", + "callback_method": "appSetup", + "active": true, + "hint": "", + "description": "" +} \ No newline at end of file diff --git a/_output/code_event_listeners/user_content_change_init_83289233a941c54c085f3fd42a6474a3.json b/_output/code_event_listeners/user_content_change_init_83289233a941c54c085f3fd42a6474a3.json new file mode 100644 index 00000000..49de8c4f --- /dev/null +++ b/_output/code_event_listeners/user_content_change_init_83289233a941c54c085f3fd42a6474a3.json @@ -0,0 +1,9 @@ +{ + "event_id": "user_content_change_init", + "execute_order": 10, + "callback_class": "Xfrocks\\Api\\Listener", + "callback_method": "userContentChangeInit", + "active": true, + "hint": "", + "description": "" +} \ No newline at end of file diff --git a/_output/cron_entries/_metadata.json b/_output/cron_entries/_metadata.json new file mode 100644 index 00000000..9054acd2 --- /dev/null +++ b/_output/cron_entries/_metadata.json @@ -0,0 +1,5 @@ +{ + "bdApiCleanUpHourly.json": { + "hash": "fa60e4aa7c53d8a6815652f9077bff8d" + } +} \ No newline at end of file diff --git a/_output/cron_entries/bdApiCleanUpHourly.json b/_output/cron_entries/bdApiCleanUpHourly.json new file mode 100644 index 00000000..fad950fa --- /dev/null +++ b/_output/cron_entries/bdApiCleanUpHourly.json @@ -0,0 +1,17 @@ +{ + "cron_class": "Xfrocks\\Api\\Cron\\CleanUp", + "cron_method": "runHourlyCleanUp", + "run_rules": { + "day_type": "dom", + "dom": [ + -1 + ], + "hours": [ + -1 + ], + "minutes": [ + 29 + ] + }, + "active": true +} \ No newline at end of file diff --git a/_output/extension_hint.php b/_output/extension_hint.php new file mode 100644 index 00000000..63b6c764 --- /dev/null +++ b/_output/extension_hint.php @@ -0,0 +1,39 @@ +{clientName}. \ No newline at end of file diff --git a/_output/phrases/cron_entry.bdApiCleanUpHourly.txt b/_output/phrases/cron_entry.bdApiCleanUpHourly.txt new file mode 100644 index 00000000..375f6eee --- /dev/null +++ b/_output/phrases/cron_entry.bdApiCleanUpHourly.txt @@ -0,0 +1 @@ +[bd] API: Hourly clean up \ No newline at end of file diff --git a/_output/phrases/option.bdApi_authCodeTTL.txt b/_output/phrases/option.bdApi_authCodeTTL.txt new file mode 100644 index 00000000..ddc39bc5 --- /dev/null +++ b/_output/phrases/option.bdApi_authCodeTTL.txt @@ -0,0 +1 @@ +Authentication Code TTL \ No newline at end of file diff --git a/_output/phrases/option.bdApi_cors.txt b/_output/phrases/option.bdApi_cors.txt new file mode 100644 index 00000000..a47ae576 --- /dev/null +++ b/_output/phrases/option.bdApi_cors.txt @@ -0,0 +1 @@ +Enable CORS \ No newline at end of file diff --git a/_output/phrases/option.bdApi_logRetentionDays.txt b/_output/phrases/option.bdApi_logRetentionDays.txt new file mode 100644 index 00000000..4baf34d0 --- /dev/null +++ b/_output/phrases/option.bdApi_logRetentionDays.txt @@ -0,0 +1 @@ +Log Retention \ No newline at end of file diff --git a/_output/phrases/option.bdApi_paramLimitDefault.txt b/_output/phrases/option.bdApi_paramLimitDefault.txt new file mode 100644 index 00000000..b0cd218f --- /dev/null +++ b/_output/phrases/option.bdApi_paramLimitDefault.txt @@ -0,0 +1 @@ +Default `limit` \ No newline at end of file diff --git a/_output/phrases/option.bdApi_paramLimitMax.txt b/_output/phrases/option.bdApi_paramLimitMax.txt new file mode 100644 index 00000000..93a012f9 --- /dev/null +++ b/_output/phrases/option.bdApi_paramLimitMax.txt @@ -0,0 +1 @@ +Maximum `limit` \ No newline at end of file diff --git a/_output/phrases/option.bdApi_paramPageMax.txt b/_output/phrases/option.bdApi_paramPageMax.txt new file mode 100644 index 00000000..4c8cd9a4 --- /dev/null +++ b/_output/phrases/option.bdApi_paramPageMax.txt @@ -0,0 +1 @@ +Maximum `page` \ No newline at end of file diff --git a/_output/phrases/option.bdApi_refreshTokenTTLDays.txt b/_output/phrases/option.bdApi_refreshTokenTTLDays.txt new file mode 100644 index 00000000..4db961fd --- /dev/null +++ b/_output/phrases/option.bdApi_refreshTokenTTLDays.txt @@ -0,0 +1 @@ +Refresh Token TTL \ No newline at end of file diff --git a/_output/phrases/option.bdApi_restrictAccess.txt b/_output/phrases/option.bdApi_restrictAccess.txt new file mode 100644 index 00000000..015aa634 --- /dev/null +++ b/_output/phrases/option.bdApi_restrictAccess.txt @@ -0,0 +1 @@ +Restrict Access \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionColumnThreadPost.txt b/_output/phrases/option.bdApi_subscriptionColumnThreadPost.txt new file mode 100644 index 00000000..d460fb57 --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionColumnThreadPost.txt @@ -0,0 +1 @@ +Subscription column thread post \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionColumnUser.txt b/_output/phrases/option.bdApi_subscriptionColumnUser.txt new file mode 100644 index 00000000..fefff351 --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionColumnUser.txt @@ -0,0 +1 @@ +Subscription column user \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionColumnUserNotification.txt b/_output/phrases/option.bdApi_subscriptionColumnUserNotification.txt new file mode 100644 index 00000000..069eeccd --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionColumnUserNotification.txt @@ -0,0 +1 @@ +Subscription column user notification \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionThreadPost.txt b/_output/phrases/option.bdApi_subscriptionThreadPost.txt new file mode 100644 index 00000000..273e3ec8 --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionThreadPost.txt @@ -0,0 +1 @@ +Accept Subscription for Thread Post \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionUser.txt b/_output/phrases/option.bdApi_subscriptionUser.txt new file mode 100644 index 00000000..7f40970b --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionUser.txt @@ -0,0 +1 @@ +Accept Subscription for User \ No newline at end of file diff --git a/_output/phrases/option.bdApi_subscriptionUserNotification.txt b/_output/phrases/option.bdApi_subscriptionUserNotification.txt new file mode 100644 index 00000000..0966cc13 --- /dev/null +++ b/_output/phrases/option.bdApi_subscriptionUserNotification.txt @@ -0,0 +1 @@ +Accept Subscription for User Alert \ No newline at end of file diff --git a/_output/phrases/option.bdApi_tokenTTL.txt b/_output/phrases/option.bdApi_tokenTTL.txt new file mode 100644 index 00000000..e0dff750 --- /dev/null +++ b/_output/phrases/option.bdApi_tokenTTL.txt @@ -0,0 +1 @@ +Token TTL \ No newline at end of file diff --git a/_output/phrases/option.bdApi_userNotificationConversation.txt b/_output/phrases/option.bdApi_userNotificationConversation.txt new file mode 100644 index 00000000..ae70b525 --- /dev/null +++ b/_output/phrases/option.bdApi_userNotificationConversation.txt @@ -0,0 +1 @@ +...also notify about conversation \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_authCodeTTL.txt b/_output/phrases/option_explain.bdApi_authCodeTTL.txt new file mode 100644 index 00000000..d7f06f47 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_authCodeTTL.txt @@ -0,0 +1 @@ +Enter the number of seconds before authentication code expires itself. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_cors.txt b/_output/phrases/option_explain.bdApi_cors.txt new file mode 100644 index 00000000..c838382e --- /dev/null +++ b/_output/phrases/option_explain.bdApi_cors.txt @@ -0,0 +1 @@ +Cross-Origin Resource Sharing (CORS) is a specification that enables truly open access across domain-boundaries. Enable this option to add the CORS headers to API responses. For more information regarding CORS, see here. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_logRetentionDays.txt b/_output/phrases/option_explain.bdApi_logRetentionDays.txt new file mode 100644 index 00000000..26c037b7 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_logRetentionDays.txt @@ -0,0 +1 @@ +Enter the number of days to keep API log entries. If you don't want to log anything, enter 0 (zero). \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_paramLimitDefault.txt b/_output/phrases/option_explain.bdApi_paramLimitDefault.txt new file mode 100644 index 00000000..7e7dbaf8 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_paramLimitDefault.txt @@ -0,0 +1 @@ +Enter the default value for a request `limit` parameter. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_paramLimitMax.txt b/_output/phrases/option_explain.bdApi_paramLimitMax.txt new file mode 100644 index 00000000..47f37c06 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_paramLimitMax.txt @@ -0,0 +1 @@ +Enter the maximum value for a request `limit` parameter. Enter 0 to have no limit. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_paramPageMax.txt b/_output/phrases/option_explain.bdApi_paramPageMax.txt new file mode 100644 index 00000000..6fc6dac0 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_paramPageMax.txt @@ -0,0 +1 @@ +Enter the maximum value for a request `page` parameter. Enter 0 to have no limit. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_refreshTokenTTLDays.txt b/_output/phrases/option_explain.bdApi_refreshTokenTTLDays.txt new file mode 100644 index 00000000..94ee681b --- /dev/null +++ b/_output/phrases/option_explain.bdApi_refreshTokenTTLDays.txt @@ -0,0 +1 @@ +Enter the number of days before refresh token expires itself. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_restrictAccess.txt b/_output/phrases/option_explain.bdApi_restrictAccess.txt new file mode 100644 index 00000000..5c367e47 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_restrictAccess.txt @@ -0,0 +1 @@ +Enable this option to deny access to the API if no authentication is provided. Guest access it still possible with One Time Token. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionColumnThreadPost.txt b/_output/phrases/option_explain.bdApi_subscriptionColumnThreadPost.txt new file mode 100644 index 00000000..da34175c --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionColumnThreadPost.txt @@ -0,0 +1 @@ +You have to create the field with a query like this: ALTER TABLE `xf_thread` ADD COLUMN `bdapi_thread_post` MEDIUMBLOB \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionColumnUser.txt b/_output/phrases/option_explain.bdApi_subscriptionColumnUser.txt new file mode 100644 index 00000000..302ce5fa --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionColumnUser.txt @@ -0,0 +1 @@ +You have to create the field with a query like this: ALTER TABLE `xf_user_option` ADD COLUMN `bdapi_user` MEDIUMBLOB \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionColumnUserNotification.txt b/_output/phrases/option_explain.bdApi_subscriptionColumnUserNotification.txt new file mode 100644 index 00000000..80421368 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionColumnUserNotification.txt @@ -0,0 +1 @@ +You have to create the field with a query like this: ALTER TABLE `xf_user_option` ADD COLUMN `bdapi_user_notification` MEDIUMBLOB \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionThreadPost.txt b/_output/phrases/option_explain.bdApi_subscriptionThreadPost.txt new file mode 100644 index 00000000..11e84ab9 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionThreadPost.txt @@ -0,0 +1 @@ +Enable this option to allow clients to subscribe for thread post changes. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionUser.txt b/_output/phrases/option_explain.bdApi_subscriptionUser.txt new file mode 100644 index 00000000..3896e3be --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionUser.txt @@ -0,0 +1 @@ +Enable this option to allow clients to subscribe for user changes. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_subscriptionUserNotification.txt b/_output/phrases/option_explain.bdApi_subscriptionUserNotification.txt new file mode 100644 index 00000000..02e6cc7a --- /dev/null +++ b/_output/phrases/option_explain.bdApi_subscriptionUserNotification.txt @@ -0,0 +1 @@ +Enable this option to allow clients to subscribe for user alerts. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_tokenTTL.txt b/_output/phrases/option_explain.bdApi_tokenTTL.txt new file mode 100644 index 00000000..10e0ad97 --- /dev/null +++ b/_output/phrases/option_explain.bdApi_tokenTTL.txt @@ -0,0 +1 @@ +Enter the number of seconds before access token expires itself. \ No newline at end of file diff --git a/_output/phrases/option_explain.bdApi_userNotificationConversation.txt b/_output/phrases/option_explain.bdApi_userNotificationConversation.txt new file mode 100644 index 00000000..f478f1eb --- /dev/null +++ b/_output/phrases/option_explain.bdApi_userNotificationConversation.txt @@ -0,0 +1 @@ +Enable this option to notify clients, who subscribed for user alerts, about conversation messages. \ No newline at end of file diff --git a/_output/phrases/option_group.bdApi.txt b/_output/phrases/option_group.bdApi.txt new file mode 100644 index 00000000..60676377 --- /dev/null +++ b/_output/phrases/option_group.bdApi.txt @@ -0,0 +1 @@ +[bd] API \ No newline at end of file diff --git a/_output/phrases/option_group_description.bdApi.txt b/_output/phrases/option_group_description.bdApi.txt new file mode 100644 index 00000000..e69de29b diff --git a/_output/phrases/permission.general_bdApi_clientNew.txt b/_output/phrases/permission.general_bdApi_clientNew.txt new file mode 100644 index 00000000..3d5caf0e --- /dev/null +++ b/_output/phrases/permission.general_bdApi_clientNew.txt @@ -0,0 +1 @@ +Create API Client \ No newline at end of file diff --git a/_output/routes/_metadata.json b/_output/routes/_metadata.json new file mode 100644 index 00000000..100d4ca9 --- /dev/null +++ b/_output/routes/_metadata.json @@ -0,0 +1,20 @@ +{ + "admin_api-auth-codes_.json": { + "hash": "6469729b1d6acf6ef3a1e48305f42d4f" + }, + "admin_api-clients_.json": { + "hash": "c63bed7a16636cfe3c555b020924faa4" + }, + "admin_api-logs_.json": { + "hash": "9a6ce8d88083a19dc5b06a760e46cd2a" + }, + "admin_api-refresh-tokens_.json": { + "hash": "b5facc5b7eb59abb5f8e6c414a72de28" + }, + "admin_api-subscriptions_.json": { + "hash": "0ab4f31d2a6edbebc3d3aec220915b66" + }, + "admin_api-tokens_.json": { + "hash": "dd62b157057503a970bab9bc7963cf80" + } +} \ No newline at end of file diff --git a/_output/routes/admin_api-auth-codes_.json b/_output/routes/admin_api-auth-codes_.json new file mode 100644 index 00000000..8107790e --- /dev/null +++ b/_output/routes/admin_api-auth-codes_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-auth-codes", + "sub_name": "", + "format": ":int/", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:AuthCode", + "context": "XfrocksApiAuthCodes", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/routes/admin_api-clients_.json b/_output/routes/admin_api-clients_.json new file mode 100644 index 00000000..4d5fa57e --- /dev/null +++ b/_output/routes/admin_api-clients_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-clients", + "sub_name": "", + "format": ":str", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:Client", + "context": "XfrocksApiClients", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/routes/admin_api-logs_.json b/_output/routes/admin_api-logs_.json new file mode 100644 index 00000000..6aa21946 --- /dev/null +++ b/_output/routes/admin_api-logs_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-logs", + "sub_name": "", + "format": ":int/", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:Log", + "context": "XfrocksApiLogs", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/routes/admin_api-refresh-tokens_.json b/_output/routes/admin_api-refresh-tokens_.json new file mode 100644 index 00000000..0a4a1000 --- /dev/null +++ b/_output/routes/admin_api-refresh-tokens_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-refresh-tokens", + "sub_name": "", + "format": ":int/", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:RefreshToken", + "context": "XfrocksApiRefreshTokens", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/routes/admin_api-subscriptions_.json b/_output/routes/admin_api-subscriptions_.json new file mode 100644 index 00000000..02da1abb --- /dev/null +++ b/_output/routes/admin_api-subscriptions_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-subscriptions", + "sub_name": "", + "format": ":int/", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:Subscription", + "context": "XfrocksApiSubscriptions", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/routes/admin_api-tokens_.json b/_output/routes/admin_api-tokens_.json new file mode 100644 index 00000000..e352efbf --- /dev/null +++ b/_output/routes/admin_api-tokens_.json @@ -0,0 +1,11 @@ +{ + "route_type": "admin", + "route_prefix": "api-tokens", + "sub_name": "", + "format": ":int/", + "build_class": "", + "build_method": "", + "controller": "Xfrocks\\Api:Token", + "context": "XfrocksApiTokens", + "action_prefix": "" +} \ No newline at end of file diff --git a/_output/style_properties/_metadata.json b/_output/style_properties/_metadata.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/_output/style_properties/_metadata.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/_output/template_modifications/_metadata.json b/_output/template_modifications/_metadata.json new file mode 100644 index 00000000..dfeee0f9 --- /dev/null +++ b/_output/template_modifications/_metadata.json @@ -0,0 +1,8 @@ +{ + "public/bdapi_account_wrapper.json": { + "hash": "16511fd73c9bb411bde6d1e58cba4ad2" + }, + "public/bdapi_bb_code_tag_quote.json": { + "hash": "52097cabf0e25e853199e19d3f5821bb" + } +} \ No newline at end of file diff --git a/_output/template_modifications/public/bdapi_account_wrapper.json b/_output/template_modifications/public/bdapi_account_wrapper.json new file mode 100644 index 00000000..64e3aad6 --- /dev/null +++ b/_output/template_modifications/public/bdapi_account_wrapper.json @@ -0,0 +1,9 @@ +{ + "template": "account_wrapper", + "description": "Render account/api link.", + "execution_order": 10, + "enabled": true, + "action": "str_replace", + "find": "", + "replace": "$0\n" +} \ No newline at end of file diff --git a/_output/template_modifications/public/bdapi_bb_code_tag_quote.json b/_output/template_modifications/public/bdapi_bb_code_tag_quote.json new file mode 100644 index 00000000..795a5ab3 --- /dev/null +++ b/_output/template_modifications/public/bdapi_bb_code_tag_quote.json @@ -0,0 +1,9 @@ +{ + "template": "bb_code_tag_quote", + "description": "Remove click to expand element", + "execution_order": 10, + "enabled": true, + "action": "str_replace", + "find": "", + "replace": "$0" +} \ No newline at end of file diff --git a/_output/templates/_metadata.json b/_output/templates/_metadata.json new file mode 100644 index 00000000..85a2ea3c --- /dev/null +++ b/_output/templates/_metadata.json @@ -0,0 +1,82 @@ +{ + "admin/bdapi_column_option_onoff.html": { + "version_id": 2000014, + "version_string": "2.0.0 Alpha 4", + "hash": "0e5d7f1abae08a24192f5de9ccaa93e1" + }, + "admin/bdapi_entity_delete.html": { + "version_id": 2000031, + "version_string": "2.0.0 Beta 1", + "hash": "6b4d4c43aec7949d705dd36d09a53a34" + }, + "admin/bdapi_entity_edit.html": { + "version_id": 2000031, + "version_string": "2.0.0 Beta 1", + "hash": "de660feaf87fef084cf1385e462941e5" + }, + "admin/bdapi_entity_list.html": { + "version_id": 2000031, + "version_string": "2.0.0 Beta 1", + "hash": "fcaa4500cbacc33f1a01cf5a82bc0359" + }, + "admin/bdapi_log_view.html": { + "version_id": 2000015, + "version_string": "2.0.0 Alpha 5", + "hash": "36388a7eeb2c4a8a283f72a0f240205c" + }, + "public/alert_conversation_message_bdapi_reply.html": { + "version_id": 2000031, + "version_string": "2.0.0 Beta 1", + "hash": "67dcd4612ef598f62cd3e40f2ea0d43c" + }, + "public/bdapi_account_api.html": { + "version_id": 2000134, + "version_string": "2.0.1 Beta 4", + "hash": "24b1f2999cdf3cce87d1949b57f12379" + }, + "public/bdapi_account_api_client_add.html": { + "version_id": 2000000, + "version_string": "2.0.0", + "hash": "8e76df6bb301823bd561cb526743b62b" + }, + "public/bdapi_account_api_client_delete.html": { + "version_id": 2000000, + "version_string": "2.0.0", + "hash": "93e134c48636c6ae563764857313750c" + }, + "public/bdapi_account_api_client_edit.html": { + "version_id": 2000000, + "version_string": "2.0.0", + "hash": "15f93fbf695c367999738d2e2032d73f" + }, + "public/bdapi_account_api_update_scope.html": { + "version_id": 2000134, + "version_string": "2.0.1 Beta 4", + "hash": "49e8bd00eacaa13987d8373320def269" + }, + "public/bdapi_account_authorize.html": { + "version_id": 2000015, + "version_string": "2.0.0 Alpha 5", + "hash": "3496982c65002dbbc051ec8bdb856e3c" + }, + "public/bdapi_account_wrapper.html": { + "version_id": 2000000, + "version_string": "2.0.0", + "hash": "ba083eca31bf1baeb18250a651af8551" + }, + "public/bdapi_client_macros.html": { + "version_id": 2000015, + "version_string": "2.0.0 Alpha 5", + "hash": "c8ede50f973589cb68608746320a2411" + }, + "public/bdapi_misc_chr.html": { + "version_id": 2000132, + "version_string": "2.0.1 Beta 2", + "hash": "fdb22cdc038b213a303c7a835e82450e" + }, + "public/bdapi_misc_chr_container.html": { + "version_id": 2000132, + "version_string": "2.0.1 Beta 2", + "hash": "59ee621597c8a9b6ff52d87b79fbb9fe" + } +} \ No newline at end of file diff --git a/_output/templates/admin/bdapi_column_option_onoff.html b/_output/templates/admin/bdapi_column_option_onoff.html new file mode 100644 index 00000000..703b5571 --- /dev/null +++ b/_output/templates/admin/bdapi_column_option_onoff.html @@ -0,0 +1,10 @@ + + + {$option.title} + + {$explainHtml} + + + \ No newline at end of file diff --git a/_output/templates/admin/bdapi_entity_delete.html b/_output/templates/admin/bdapi_entity_delete.html new file mode 100644 index 00000000..0bf0ae30 --- /dev/null +++ b/_output/templates/admin/bdapi_entity_delete.html @@ -0,0 +1,19 @@ +{{ phrase('confirm_action') }} + + +
+
+ + {{ phrase('please_confirm_that_you_want_to_delete_following:') }} + + + {$entityLabel} + + {$entityLabel} + + + +
+ +
+
\ No newline at end of file diff --git a/_output/templates/admin/bdapi_entity_edit.html b/_output/templates/admin/bdapi_entity_edit.html new file mode 100644 index 00000000..45d00d12 --- /dev/null +++ b/_output/templates/admin/bdapi_entity_edit.html @@ -0,0 +1,101 @@ + + {$phrases.add|raw} + + {$phrases.edit|raw}: {$controller.getEntityLabel($entity)} + + + + + + + +
+ +
+ + + + + +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ phrase('you_may_use_html') }} + + + + + + + + + + + + + \ No newline at end of file diff --git a/_output/templates/admin/bdapi_entity_list.html b/_output/templates/admin/bdapi_entity_list.html new file mode 100644 index 00000000..7ae3bde4 --- /dev/null +++ b/_output/templates/admin/bdapi_entity_list.html @@ -0,0 +1,84 @@ +{$phrases.entities|raw} + + + + {$phrases.add|raw} + + + + + + + + +
+ +
+ + + + + +
+
+ +
+
+ + + + + + {{ phrase('more_records_matching_filter_more_specific') }} + + + + +
+
+ + +
+ +
{{ phrase('no_items_have_been_created_yet') }}
+
+ + + + + + + + + + + + + + {$popup|raw} + + + + + \ No newline at end of file diff --git a/_output/templates/admin/bdapi_log_view.html b/_output/templates/admin/bdapi_log_view.html new file mode 100644 index 00000000..dd4ad300 --- /dev/null +++ b/_output/templates/admin/bdapi_log_view.html @@ -0,0 +1,17 @@ +{{ phrase('bdapi_log_entry') }} + +
+
+
+ {$log.client_id} + {{ $log.User ? $log.User.username : '' }} + {$log.ip_address} + + {$log.request_method} + {$log.request_uri} + {{ dump($log.request_data) }} + {$log.response_code} + {{ dump($log.response_output) }} +
+
+
\ No newline at end of file diff --git a/_output/templates/public/alert_conversation_message_bdapi_reply.html b/_output/templates/public/alert_conversation_message_bdapi_reply.html new file mode 100644 index 00000000..acd8350d --- /dev/null +++ b/_output/templates/public/alert_conversation_message_bdapi_reply.html @@ -0,0 +1,4 @@ +{{ phrase('bdapi_x_replied_in_the_conversation_y', { + 'name': username_link($user, false, {'defaultname': $alert.username}), + 'title': '' . $content.Conversation.title . '' +}) }} \ No newline at end of file diff --git a/_output/templates/public/bdapi_account_api.html b/_output/templates/public/bdapi_account_api.html new file mode 100644 index 00000000..7af8164e --- /dev/null +++ b/_output/templates/public/bdapi_account_api.html @@ -0,0 +1,80 @@ +{{ phrase('bdapi_home') }} + + + + + + {{ phrase('bdapi_client_add') }} + + + +
+
+

{{ phrase('bdapi_client_entities') }}

+
    + +
  1. + +
  2. +
    +
+
+
+ + +
+
+

{{ phrase('bdapi_api_access_tokens') }}

+
    + +
  1. + +
  2. +
    +
+
+ +
+ +
+
+
+ + +
+
+

+ {$client.name} +

+ +
+ {$client.description} +
    + +
  • {$scopes.{$userScope.scope}}
  • +
    +
+
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/_output/templates/public/bdapi_account_api_client_add.html b/_output/templates/public/bdapi_account_api_client_add.html new file mode 100644 index 00000000..961985f1 --- /dev/null +++ b/_output/templates/public/bdapi_account_api_client_add.html @@ -0,0 +1,5 @@ +{{ phrase('bdapi_client_add') }} + + + + \ No newline at end of file diff --git a/_output/templates/public/bdapi_account_api_client_delete.html b/_output/templates/public/bdapi_account_api_client_delete.html new file mode 100644 index 00000000..b8e9c51b --- /dev/null +++ b/_output/templates/public/bdapi_account_api_client_delete.html @@ -0,0 +1,20 @@ +{{ phrase('confirm_action') }} + + + + +
+
+ + {{ phrase('please_confirm_that_you_want_to_delete_following:') }} + + {$client.name} + ({{ phrase('bdapi_client_id:') }} {$client.client_id}) + + +
+ + + +
+
\ No newline at end of file diff --git a/_output/templates/public/bdapi_account_api_client_edit.html b/_output/templates/public/bdapi_account_api_client_edit.html new file mode 100644 index 00000000..0f361e22 --- /dev/null +++ b/_output/templates/public/bdapi_account_api_client_edit.html @@ -0,0 +1,5 @@ +{{ phrase('bdapi_client_edit') }} + + + + \ No newline at end of file diff --git a/_output/templates/public/bdapi_account_api_update_scope.html b/_output/templates/public/bdapi_account_api_update_scope.html new file mode 100644 index 00000000..ad2c5a1f --- /dev/null +++ b/_output/templates/public/bdapi_account_api_update_scope.html @@ -0,0 +1,31 @@ +{{ phrase('bdapi_update_scope') }} + + + + +
+
+ + {{ phrase('bdapi_update_scope_explain', { + 'name': '' . $client.name . '', + 'revoke': phrase('bdapi_revoke') + }) }} + +
+ + + + {$scopesPhrase.{$userScope.scope}} + + + + + + {{ phrase('bdapi_revoke') }} + + +
+ + {$client.client_id} +
\ No newline at end of file diff --git a/_output/templates/public/bdapi_account_authorize.html b/_output/templates/public/bdapi_account_authorize.html new file mode 100644 index 00000000..897e1550 --- /dev/null +++ b/_output/templates/public/bdapi_account_authorize.html @@ -0,0 +1,42 @@ +{{ phrase('bdapi_authorize_access') }} + + + + +
+
+ + + {{ phrase('bdapi_please_confirm_to_authorize_access_auto') }} + + + {{ phrase('bdapi_please_confirm_to_authorize_access:') }} + + + + + {$client.name} + + + + + + {$scopeDescription} + + + + + + {{ phrase('bdapi_you_will_be_redirected_x', { + 'redirectUri': {$linkParams.redirect_uri}, + 'clientName': {$client.name} + }) }} + +
+ + + + +
+
diff --git a/_output/templates/public/bdapi_account_wrapper.html b/_output/templates/public/bdapi_account_wrapper.html new file mode 100644 index 00000000..ad9543f1 --- /dev/null +++ b/_output/templates/public/bdapi_account_wrapper.html @@ -0,0 +1,3 @@ + + {{ phrase('bdapi_home') }} + \ No newline at end of file diff --git a/_output/templates/public/bdapi_client_macros.html b/_output/templates/public/bdapi_client_macros.html new file mode 100644 index 00000000..8f333330 --- /dev/null +++ b/_output/templates/public/bdapi_client_macros.html @@ -0,0 +1,72 @@ + +
+
+

+ {$client.name} +

+ +
+ {$client.description} +
+ +
+ +
+
+
+
+ + + +
+
+ + + + + + + +
+ + + + + + +
+
+
diff --git a/_output/templates/public/bdapi_misc_chr.html b/_output/templates/public/bdapi_misc_chr.html new file mode 100644 index 00000000..70c2e87f --- /dev/null +++ b/_output/templates/public/bdapi_misc_chr.html @@ -0,0 +1,38 @@ + + + + + + + + + + + {$css|raw} + + + + + + + + + + + + {$js|raw} + + + + + + + + + +border: 0; margin: 0; padding: 0 +
+
+
{$html|raw}
+
+
diff --git a/_output/templates/public/bdapi_misc_chr_container.html b/_output/templates/public/bdapi_misc_chr_container.html new file mode 100644 index 00000000..4aeadb36 --- /dev/null +++ b/_output/templates/public/bdapi_misc_chr_container.html @@ -0,0 +1,22 @@ + + + + + + + + + + +{$content|raw} + + + \ No newline at end of file diff --git a/addon.json b/addon.json new file mode 100644 index 00000000..9c0268a0 --- /dev/null +++ b/addon.json @@ -0,0 +1,19 @@ +{ + "legacy_addon_id": "bdApi", + "title": "[bd] API", + "description": "", + "version_id": 2020032, + "version_string": "2.2.0 Beta 2", + "dev": "", + "dev_url": "", + "faq_url": "", + "support_url": "", + "extra_urls": [], + "require": { + "XF": [ + 2020000, + "XenForo 2.2.0" + ] + }, + "icon": "" +} diff --git a/android_demo/README.md b/android_demo/README.md deleted file mode 100644 index 1508fda0..00000000 --- a/android_demo/README.md +++ /dev/null @@ -1 +0,0 @@ -Moved to https://github.com/xfrocks/bdApi_Android diff --git a/build.json b/build.json new file mode 100644 index 00000000..ccdb9da8 --- /dev/null +++ b/build.json @@ -0,0 +1,8 @@ +{ + "additional_files": [ + "api/bootstrap.php", + "api/index.php", + "js/Xfrocks/Api" + ], + "minify": "*" +} diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..108bd6b5 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "config": { + "platform": { + "php": "5.6.0" + } + }, + "require": { + "league/oauth2-server": "4.1.7" + }, + "replace": { + "league/event": "*" + }, + "scripts": { + "build-release": "xf-addon--build-release Xfrocks/Api", + "export": "composer install --quiet && composer test && xf-dev--export--addon.sh Xfrocks/Api", + "export-only": "xf-dev--export--addon.sh Xfrocks/Api", + "quick-check": "export ADDON_DIR=/var/www/html/src/addons/Xfrocks/Api && devhelper-phpcs.sh $ADDON_DIR && devhelper-phpstan.sh $ADDON_DIR", + "test": "cmd-php.sh xfrocks-api:pre-test && cd _files/tests && composer install --quiet && composer test" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..769a787c --- /dev/null +++ b/composer.lock @@ -0,0 +1,314 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "df749d439aab1bb644b5875ea054c501", + "packages": [ + { + "name": "league/oauth2-server", + "version": "4.1.7", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-server.git", + "reference": "138524984ac472652c69399529a35b6595cf22d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/138524984ac472652c69399529a35b6595cf22d3", + "reference": "138524984ac472652c69399529a35b6595cf22d3", + "shasum": "" + }, + "require": { + "league/event": "~2.1", + "php": ">=5.4.0", + "symfony/http-foundation": "~2.4|~3.0" + }, + "replace": { + "league/oauth2server": "*", + "lncd/oauth2": "*" + }, + "require-dev": { + "mockery/mockery": "0.9.*", + "phpunit/phpunit": "4.3.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Server\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + } + ], + "description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.", + "homepage": "http://oauth2.thephpleague.com/", + "keywords": [ + "Authentication", + "api", + "auth", + "authorisation", + "authorization", + "oauth", + "oauth 2", + "oauth 2.0", + "oauth2", + "protect", + "resource", + "secure", + "server" + ], + "time": "2018-06-23T16:27:31+00:00" + }, + { + "name": "paragonie/random_compat", + "version": "v2.0.18", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "0a58ef6e3146256cc3dc7cc393927bcc7d1b72db" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/0a58ef6e3146256cc3dc7cc393927bcc7d1b72db", + "reference": "0a58ef6e3146256cc3dc7cc393927bcc7d1b72db", + "shasum": "" + }, + "require": { + "php": ">=5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "autoload": { + "files": [ + "lib/random.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "time": "2019-01-03T20:59:08+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v3.4.42", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "fbd216d2304b1a3fe38d6392b04729c8dd356359" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fbd216d2304b1a3fe38d6392b04729c8dd356359", + "reference": "fbd216d2304b1a3fe38d6392b04729c8dd356359", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php70": "~1.6" + }, + "require-dev": { + "symfony/expression-language": "~2.8|~3.0|~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpFoundation Component", + "homepage": "https://symfony.com", + "time": "2020-05-16T13:15:54+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.17.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "7110338d81ce1cbc3e273136e4574663627037a7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/7110338d81ce1cbc3e273136e4574663627037a7", + "reference": "7110338d81ce1cbc3e273136e4574663627037a7", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "time": "2020-06-06T08:46:27+00:00" + }, + { + "name": "symfony/polyfill-php70", + "version": "v1.17.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php70.git", + "reference": "471b096aede7025bace8eb356b9ac801aaba7e2d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php70/zipball/471b096aede7025bace8eb356b9ac801aaba7e2d", + "reference": "471b096aede7025bace8eb356b9ac801aaba7e2d", + "shasum": "" + }, + "require": { + "paragonie/random_compat": "~1.0|~2.0|~9.99", + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.17-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Php70\\": "" + }, + "files": [ + "bootstrap.php" + ], + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "time": "2020-06-06T08:46:27+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "platform-overrides": { + "php": "5.6.0" + } +} diff --git a/node_pushserver/README.md b/node_pushserver/README.md deleted file mode 100644 index 25a172ae..00000000 --- a/node_pushserver/README.md +++ /dev/null @@ -1 +0,0 @@ -Moved to https://github.com/xfrocks/node_pubhubsubbub_pushserver diff --git a/php_demo/.gitignore b/php_demo/.gitignore deleted file mode 100644 index 5a38c413..00000000 --- a/php_demo/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.htaccess -config.php -vendor/ diff --git a/php_demo/Procfile b/php_demo/Procfile deleted file mode 100644 index 60aaff09..00000000 --- a/php_demo/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: vendor/bin/heroku-php-apache2 ./ \ No newline at end of file diff --git a/php_demo/composer.json b/php_demo/composer.json deleted file mode 100644 index 9f6d4fb5..00000000 --- a/php_demo/composer.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "require": { - }, - "require-dev": { - "heroku/heroku-buildpack-php": "*" - } -} \ No newline at end of file diff --git a/php_demo/composer.lock b/php_demo/composer.lock deleted file mode 100644 index 23567b3d..00000000 --- a/php_demo/composer.lock +++ /dev/null @@ -1,69 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file" - ], - "hash": "62f7e73a170916ea57aca93de4a57c11", - "packages": [ - - ], - "packages-dev": [ - { - "name": "heroku/heroku-buildpack-php", - "version": "v66", - "source": { - "type": "git", - "url": "https://github.com/heroku/heroku-buildpack-php.git", - "reference": "f23729a3c42764b389f80a9d387c211d6061de4f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/heroku/heroku-buildpack-php/zipball/f23729a3c42764b389f80a9d387c211d6061de4f", - "reference": "f23729a3c42764b389f80a9d387c211d6061de4f", - "shasum": "" - }, - "bin": [ - "bin/heroku-hhvm-apache2", - "bin/heroku-hhvm-nginx", - "bin/heroku-php-apache2", - "bin/heroku-php-nginx" - ], - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "David Zuelke", - "email": "dz@heroku.com" - } - ], - "description": "Toolkit for starting a PHP application locally, with or without foreman, using the same config for PHP/HHVM and Apache2/Nginx as on Heroku", - "homepage": "http://github.com/heroku/heroku-buildpack-php", - "keywords": [ - "apache", - "apache2", - "foreman", - "heroku", - "hhvm", - "nginx", - "php" - ], - "time": "2015-03-05 20:37:15" - } - ], - "aliases": [ - - ], - "minimum-stability": "stable", - "stability-flags": [ - - ], - "platform": [ - - ], - "platform-dev": [ - - ] -} diff --git a/php_demo/config.php.template b/php_demo/config.php.template deleted file mode 100644 index 6f7b4391..00000000 --- a/php_demo/config.php.template +++ /dev/null @@ -1,13 +0,0 @@ - '', - 'api_key' => '', - 'api_secret' => '', - 'api_scope' => '', - - 'placeholder' => array( - 'api_root' => 'http://domain.com/xenforo/api', - 'api_key' => 'abc123', - 'api_secret' => 'xyz456', - 'api_scope' => 'read', - ), - - 'ignore_config' => false, - ), $config); -} - -function displaySetup() -{ - require(dirname(__FILE__) . '/setup.php'); - exit; -} - -function getBaseUrl() -{ - // idea from http://stackoverflow.com/questions/6768793/get-the-full-url-in-php - $ssl = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') ? true : false; - $sp = strtolower($_SERVER['SERVER_PROTOCOL']); - $protocol = substr($sp, 0, strpos($sp, '/')) . (($ssl) ? 's' : ''); - - $port = $_SERVER['SERVER_PORT']; - $port = ((!$ssl && $port == '80') || ($ssl && $port == '443')) ? '' : ':' . $port; - - // using HTTP_POST may have some security implication - $host = isset($_SERVER['HTTP_X_FORWARDED_HOST']) ? $_SERVER['HTTP_X_FORWARDED_HOST'] : (isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : null); - $host = isset($host) ? $host : $_SERVER['SERVER_NAME'] . $port; - - $baseUrl = $protocol . '://' . $host . $_SERVER['REQUEST_URI']; - $baseUrl = preg_replace('#\?.*$#', '', $baseUrl); - $baseUrl = rtrim($baseUrl, '/'); - - return $baseUrl; -} - -function getCallbackUrl() -{ - return sprintf( - '%s?action=callback', - getBaseUrl() - ); -} - -function generateJsSdkUrl($apiRoot) -{ - $url = sprintf( - '%s/index.php?assets/sdk.js', - $apiRoot - ); - - return $url; -} - -function generateOneTimeToken($apiKey, $apiSecret, $userId = 0, $accessToken = '', $ttl = 86400) -{ - $timestamp = time() + $ttl; - $once = md5($userId . $timestamp . $accessToken . $apiSecret); - - return sprintf('%d,%d,%s,%s', $userId, $timestamp, $once, $apiKey); -} - -function makeRequest($url, $apiRoot, $accessToken) -{ - if (strpos($url, $apiRoot) === false) { - $url = sprintf( - '%s/index.php?%s&oauth_token=%s', - $apiRoot, - $url, - rawurlencode($accessToken) - ); - } - - $body = @file_get_contents($url); - $json = @json_decode($body, true); - - return array($body, $json); -} - -function makeSubscriptionRequest($config, $topic, $fwd, $accessToken = null) -{ - $subscriptionUrl = sprintf( - '%s/index.php?subscriptions', - $config['api_root'] - ); - - $callbackUrl = sprintf( - '%s/subscriptions.php?fwd=%s', - rtrim(preg_replace('#index.php$#', '', getBaseUrl()), '/'), - rawurlencode($fwd) - ); - - $postFields = array( - 'hub.callback' => $callbackUrl, - 'hub.mode' => !empty($accessToken) ? 'subscribe' : 'unsubscribe', - 'hub.topic' => $topic, - 'oauth_token' => $accessToken, - 'client_id' => $config['api_key'], - ); - - return array('response' => makeCurlPost($subscriptionUrl, $postFields, false)); -} - -function makeCurlPost($url, $postFields, $getJson = true) -{ - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, $postFields); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $body = curl_exec($ch); - curl_close($ch); - - if (!$getJson) { - return $body; - } - - $json = @json_decode($body, true); - if (empty($json)) { - die('Unexpected response from server: ' . $body); - } - - return $json; -} - -function renderMessageForPostRequest($url, array $postFields) -{ - $message = 'It looks like you are testing a local installation. '; - $message .= 'Since this test server cannot reach yours, please run this command in your terminal '; - $message .= '(or equivalent) please:

'; - $message .= '
curl -XPOST "' . $url . '" \\
'; - - $postFieldKeys = array_keys($postFields); - $lastFieldKey = array_pop($postFieldKeys); - foreach ($postFields as $postFieldKey => $postFieldValue) { - $message .= sprintf( - '
-F %s=%s%s
', - $postFieldKey, - $postFieldValue, - $postFieldKey === $lastFieldKey ? '' : ' \\' - ); - } - - return $message; -} - -function renderMessageForJson($url, array $json) -{ - global $accessToken; - $html = str_replace(' ', '  ', var_export($json, true)); - - if (!empty($accessToken)) { - $offset = 0; - while (true) { - if (preg_match('#\'(?http[^\']+)\'#', $html, $matches, PREG_OFFSET_CAPTURE, $offset)) { - $offset = $matches[0][1] + strlen($matches[0][0]); - $link = $matches['link'][0]; - $replacement = null; - - if (strpos($link, $accessToken) !== false) { - // found a link - $targetUrl = sprintf( - '%s?action=request&url=%s&access_token=%s', - getBaseUrl(), - rawurlencode($link), - rawurlencode($accessToken) - ); - - $replacement = sprintf('%s', $targetUrl, $link); - } elseif (substr($link, 0, 4) === 'http') { - $replacement = sprintf('%1$s', $link); - } - - if (!empty($replacement)) { - $html = substr_replace( - $html, - $replacement, - $matches['link'][1], - strlen($matches['link'][0]) - ); - $offset = $matches[0][1] + strlen($replacement); - } - } else { - break; - } - } - } - - return sprintf( - '
Sent Request: %s
Received Response: %s
', - $url, - nl2br($html) - ); -} - -function renderAccessTokenMessage($tokenUrl, array $json) -{ - global $config, $accessToken; - - if (!empty($json['access_token'])) { - $accessToken = $json['access_token']; - $message = sprintf( - 'Obtained access token successfully!
' - . 'Scopes: %s
' - . 'Expires At: %s
', - $json['scope'], - date('c', time() + $json['expires_in']) - ); - - if (!empty($json['refresh_token'])) { - $message .= sprintf('Refresh Token: %1$s
', $json['refresh_token']); - } else { - $message .= sprintf('Refresh Token: N/A
'); - } - - list($body, $json) = makeRequest('index', $config['api_root'], $accessToken); - if (!empty($json['links'])) { - $message .= '
' . renderMessageForJson('index', $json); - } - } else { - $message = renderMessageForJson($tokenUrl, $json); - } - - return $message; -} - -function isLocal($apiRoot) { - $apiRootHost = parse_url($apiRoot, PHP_URL_HOST); - $isLocal = in_array($apiRootHost, array( - 'localhost', - '127.0.0.1', - 'local.dev', - )); - - return $isLocal; -} - -function bitlyShorten($token, $url) -{ - $bitlyUrl = sprintf( - '%s?access_token=%s&longUrl=%s&domain=j.mp&format=txt', - 'https://api-ssl.bitly.com/v3/shorten', - rawurlencode($token), - rawurlencode($url) - ); - - $body = @file_get_contents($bitlyUrl); - if (!empty($body)) { - $url = $body; - } - - return $url; -} \ No newline at end of file diff --git a/php_demo/html/footer.php b/php_demo/html/footer.php deleted file mode 100644 index 691287b6..00000000 --- a/php_demo/html/footer.php +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/php_demo/html/form_request.php b/php_demo/html/form_request.php deleted file mode 100644 index 7a1fcbc0..00000000 --- a/php_demo/html/form_request.php +++ /dev/null @@ -1,13 +0,0 @@ -
-
-
-
- -
-
- - -
\ No newline at end of file diff --git a/php_demo/html/header.php b/php_demo/html/header.php deleted file mode 100644 index d9f92336..00000000 --- a/php_demo/html/header.php +++ /dev/null @@ -1,79 +0,0 @@ - - - - - [bd] API - PHP Demo - - - - - -

- -
    -
  • API Key:
  • -
  • API Secret:
  • -
  • API Scope:
  • - -
  • Re-setup
  • - -
-
- \ No newline at end of file diff --git a/php_demo/index.php b/php_demo/index.php deleted file mode 100644 index e1394d0f..00000000 --- a/php_demo/index.php +++ /dev/null @@ -1,180 +0,0 @@ - 'authorization_code', - 'client_id' => $config['api_key'], - 'client_secret' => $config['api_secret'], - 'code' => $_REQUEST['code'], - 'redirect_uri' => getCallbackUrl(), - ); - - if (isLocal($config['api_root']) && !isLocal(getBaseUrl())) { - $message = renderMessageForPostRequest($tokenUrl, $postFields); - $message .= '
Afterwards, you can test JavaScript by clicking the link below.'; - break; - } - - // step 4 - $json = makeCurlPost($tokenUrl, $postFields); - $message = renderAccessTokenMessage($tokenUrl, $json); - break; - case 'refresh': - // this is the refresh token flow - if (empty($_REQUEST['refresh_token'])) { - $message = 'Refresh request must have `refresh_token` query parameter!'; - break; - } - - $tokenUrl = sprintf( - '%s/index.php?oauth/token', - $config['api_root'] - ); - - $postFields = array( - 'grant_type' => 'refresh_token', - 'client_id' => $config['api_key'], - 'client_secret' => $config['api_secret'], - 'refresh_token' => $_REQUEST['refresh_token'], - ); - - $json = makeCurlPost($tokenUrl, $postFields); - $message = renderAccessTokenMessage($tokenUrl, $json); - break; - case 'request': - // step 5 - if (!empty($accessToken) && !empty($_REQUEST['url'])) { - list($body, $json) = makeRequest($_REQUEST['url'], $config['api_root'], $accessToken); - if (empty($json)) { - $message = 'Unexpected response from server: ' . var_export($body, true); - } else { - $message = renderMessageForJson($_REQUEST['url'], $json); - - if ($_REQUEST['url'] === 'users/me') { - $topic = 'user_notification_' . $json['user']['user_id']; - } - } - } - break; - case 'subscribe': - case 'unsubscribe': - if (empty($_REQUEST['topic'])) { - $message = 'Subscription request must have `topic` parameter!'; - break; - } - $topic = $_REQUEST['topic']; - - if (empty($_REQUEST['fwd'])) { - $message = 'Subscription request must have `fwd` parameter!'; - break; - } - $fwd = $_REQUEST['fwd']; - - if ($action == 'subscribe') { - $json = makeSubscriptionRequest($config, $topic, $fwd, $accessToken); - } else { - $json = makeSubscriptionRequest($config, $topic, $fwd); - } - - $message = renderMessageForJson($action, $json); - break; - case 'authorize': - default: - // step 1 - $authorizeUrl = sprintf( - '%s/index.php?oauth/authorize&response_type=code&client_id=%s&scope=%s&redirect_uri=%s', - $config['api_root'], - rawurlencode($config['api_key']), - rawurlencode($config['api_scope']), - rawurlencode(getCallbackUrl()) - ); - - $message = sprintf( - '

Authorization (step 1)

' - . 'Click here to go to %s and start the authorizing flow.' - . ' Or click here and try the JWT Bearer grant type.', - $authorizeUrl, - parse_url($authorizeUrl, PHP_URL_HOST) - ); - break; -} - -?> - - - - -
-
- - - -

Test Sending Request

- -
- -

Test Subscriptions

-
- - - -
- -
-
-
- -
-
-
- - -
-
-
- - -

Test JavaScript

-

Click here

- - \ No newline at end of file diff --git a/php_demo/js.php b/php_demo/js.php deleted file mode 100644 index 2581443b..00000000 --- a/php_demo/js.php +++ /dev/null @@ -1,136 +0,0 @@ - - - - -

JavaScript SDK

-

- The API system supports a simple JavaScript SDK which can be included into any web page and - perform callback to the API server. In this demo, click the button below to issue a - .isAuthorized() check to see whether user is logged in - and has granted the specified scope. -

- -

- The code looks something like this:
- -

<script src="js/jquery-1.11.2.min.js"></script>
-
<script src=""></script>
-
<script>
-
  var api = window.SDK;
-
  api.init({ 'client_id': '' });
-
  api.isAuthorized('', function (isAuthorized, apiData) {
-
    if (isAuthorized) {
-
      alert('Hi ' + apiData.username);
-
    } else {
-
      alert('isAuthorized = false');
-
    }
-
  }
-
</script>
-

- - - - -
- -
-
-
-
- - -
- -
- -

Implicit Grant Type

-

- Other than the traditional grant types, implicit allows more creative usages of the API system. - Read more about it here. -

- - -
-

Token found from window.location.hash!

- -
- - - - -

- Click here - - to refresh the token. - - to start the authorizing flow. - -

- - \ No newline at end of file diff --git a/php_demo/js/jquery-1.11.2.min.js b/php_demo/js/jquery-1.11.2.min.js deleted file mode 100755 index 826372a2..00000000 --- a/php_demo/js/jquery-1.11.2.min.js +++ /dev/null @@ -1,5 +0,0 @@ -/*! jQuery v1.11.2 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ -!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.2",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)+1>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b=a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=hb(),z=hb(),A=hb(),B=function(a,b){return a===b&&(l=!0),0},C=1<<31,D={}.hasOwnProperty,E=[],F=E.pop,G=E.push,H=E.push,I=E.slice,J=function(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c]===b)return c;return-1},K="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",L="[\\x20\\t\\r\\n\\f]",M="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",N=M.replace("w","w#"),O="\\["+L+"*("+M+")(?:"+L+"*([*^$|!~]?=)"+L+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+N+"))|)"+L+"*\\]",P=":("+M+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+O+")*)|.*)\\)|)",Q=new RegExp(L+"+","g"),R=new RegExp("^"+L+"+|((?:^|[^\\\\])(?:\\\\.)*)"+L+"+$","g"),S=new RegExp("^"+L+"*,"+L+"*"),T=new RegExp("^"+L+"*([>+~]|"+L+")"+L+"*"),U=new RegExp("="+L+"*([^\\]'\"]*?)"+L+"*\\]","g"),V=new RegExp(P),W=new RegExp("^"+N+"$"),X={ID:new RegExp("^#("+M+")"),CLASS:new RegExp("^\\.("+M+")"),TAG:new RegExp("^("+M.replace("w","w*")+")"),ATTR:new RegExp("^"+O),PSEUDO:new RegExp("^"+P),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+L+"*(even|odd|(([+-]|)(\\d*)n|)"+L+"*(?:([+-]|)"+L+"*(\\d+)|))"+L+"*\\)|)","i"),bool:new RegExp("^(?:"+K+")$","i"),needsContext:new RegExp("^"+L+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+L+"*((?:-\\d)?\\d*)"+L+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+L+"?|("+L+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},eb=function(){m()};try{H.apply(E=I.call(v.childNodes),v.childNodes),E[v.childNodes.length].nodeType}catch(fb){H={apply:E.length?function(a,b){G.apply(a,I.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function gb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],k=b.nodeType,"string"!=typeof a||!a||1!==k&&9!==k&&11!==k)return d;if(!e&&p){if(11!==k&&(f=_.exec(a)))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return H.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName)return H.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=1!==k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+rb(o[l]);w=ab.test(a)&&pb(b.parentNode)||b,x=o.join(",")}if(x)try{return H.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function hb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ib(a){return a[u]=!0,a}function jb(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function kb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function lb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||C)-(~a.sourceIndex||C);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function nb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function ob(a){return ib(function(b){return b=+b,ib(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function pb(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=gb.support={},f=gb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=gb.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=g.documentElement,e=g.defaultView,e&&e!==e.top&&(e.addEventListener?e.addEventListener("unload",eb,!1):e.attachEvent&&e.attachEvent("onunload",eb)),p=!f(g),c.attributes=jb(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=jb(function(a){return a.appendChild(g.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(g.getElementsByClassName),c.getById=jb(function(a){return o.appendChild(a).id=u,!g.getElementsByName||!g.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(g.querySelectorAll))&&(jb(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+L+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+L+"*(?:value|"+K+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),jb(function(a){var b=g.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+L+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&jb(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",P)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===g||a.ownerDocument===v&&t(v,a)?-1:b===g||b.ownerDocument===v&&t(v,b)?1:k?J(k,a)-J(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,h=[a],i=[b];if(!e||!f)return a===g?-1:b===g?1:e?-1:f?1:k?J(k,a)-J(k,b):0;if(e===f)return lb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?lb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},g):n},gb.matches=function(a,b){return gb(a,null,null,b)},gb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return gb(b,n,null,[a]).length>0},gb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},gb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&D.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},gb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},gb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=gb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=gb.selectors={cacheLength:50,createPseudo:ib,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||gb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&gb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+L+")"+a+"("+L+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=gb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(Q," ")+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||gb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ib(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=J(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ib(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?ib(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ib(function(a){return function(b){return gb(a,b).length>0}}),contains:ib(function(a){return a=a.replace(cb,db),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ib(function(a){return W.test(a||"")||gb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:ob(function(){return[0]}),last:ob(function(a,b){return[b-1]}),eq:ob(function(a,b,c){return[0>c?c+b:c]}),even:ob(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:ob(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:ob(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:ob(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function sb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function tb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function ub(a,b,c){for(var d=0,e=b.length;e>d;d++)gb(a,b[d],c);return c}function vb(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function wb(a,b,c,d,e,f){return d&&!d[u]&&(d=wb(d)),e&&!e[u]&&(e=wb(e,f)),ib(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||ub(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:vb(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=vb(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?J(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=vb(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):H.apply(g,r)})}function xb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=sb(function(a){return a===b},h,!0),l=sb(function(a){return J(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];f>i;i++)if(c=d.relative[a[i].type])m=[sb(tb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return wb(i>1&&tb(m),i>1&&rb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&xb(a.slice(i,e)),f>e&&xb(a=a.slice(e)),f>e&&rb(a))}m.push(c)}return tb(m)}function yb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=F.call(i));s=vb(s)}H.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&gb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?ib(f):f}return h=gb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=xb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,yb(e,d)),f.selector=a}return f},i=gb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&pb(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&rb(j),!a)return H.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&pb(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=jb(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),jb(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||kb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&jb(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||kb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),jb(function(a){return null==a.getAttribute("disabled")})||kb(K,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),gb}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1; -return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h;if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function ab(){return!0}function bb(){return!1}function cb(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),hb=/^\s+/,ib=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,jb=/<([\w:]+)/,kb=/\s*$/g,rb={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:k.htmlSerialize?[0,"",""]:[1,"X
","
"]},sb=db(y),tb=sb.appendChild(y.createElement("div"));rb.optgroup=rb.option,rb.tbody=rb.tfoot=rb.colgroup=rb.caption=rb.thead,rb.th=rb.td;function ub(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ub(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function vb(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wb(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xb(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function yb(a){var b=pb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function zb(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Ab(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Bb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xb(b).text=a.text,yb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!gb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(tb.innerHTML=a.outerHTML,tb.removeChild(f=tb.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ub(f),h=ub(a),g=0;null!=(e=h[g]);++g)d[g]&&Bb(e,d[g]);if(b)if(c)for(h=h||ub(a),d=d||ub(f),g=0;null!=(e=h[g]);g++)Ab(e,d[g]);else Ab(a,f);return d=ub(f,"script"),d.length>0&&zb(d,!i&&ub(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=db(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(lb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(jb.exec(f)||["",""])[1].toLowerCase(),l=rb[i]||rb._default,h.innerHTML=l[1]+f.replace(ib,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&hb.test(f)&&p.push(b.createTextNode(hb.exec(f)[0])),!k.tbody){f="table"!==i||kb.test(f)?""!==l[1]||kb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ub(p,"input"),vb),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ub(o.appendChild(f),"script"),g&&zb(h),c)){e=0;while(f=h[e++])ob.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ub(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&zb(ub(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ub(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fb,""):void 0;if(!("string"!=typeof a||mb.test(a)||!k.htmlSerialize&&gb.test(a)||!k.leadingWhitespace&&hb.test(a)||rb[(jb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ib,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ub(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ub(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&nb.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ub(i,"script"),xb),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ub(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,yb),j=0;f>j;j++)d=g[j],ob.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qb,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Cb,Db={};function Eb(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fb(a){var b=y,c=Db[a];return c||(c=Eb(a,b),"none"!==c&&c||(Cb=(Cb||m("