dokuwiki/lib/plugins/extension/Repository.php
<?php
namespace dokuwiki\plugin\extension;
use dokuwiki\Cache\Cache;
use dokuwiki\HTTP\DokuHTTPClient;
use JsonException;
class Repository
{
public const EXTENSION_REPOSITORY_API = 'https://www.dokuwiki.org/lib/plugins/pluginrepo/api.php';
protected const CACHE_PREFIX = '##extension_manager##';
protected const CACHE_SUFFIX = '.repo';
protected const CACHE_TIME = 3600 * 24;
protected static $instance;
protected $hasAccess;
/**
* Protected Constructor
*
* Use Repository::getInstance() to get an instance
*/
protected function __construct()
{
}
/**
* @return Repository
*/
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Check if access to the repository is possible
*
* On the first call this will throw an exception if access is not possible. On subsequent calls
* it will return the cached result. Thus it is recommended to call this method once when instantiating
* the repository for the first time and handle the exception there. Subsequent calls can then be used
* to access cached data.
*
* @return bool
* @throws Exception
*/
public function checkAccess()
{
if ($this->hasAccess !== null) {
return $this->hasAccess; // we already checked
}
// check for SSL support
if (!in_array('ssl', stream_get_transports())) {
throw new Exception('nossl');
}
// ping the API
$httpclient = new DokuHTTPClient();
$httpclient->timeout = 5;
$data = $httpclient->get(self::EXTENSION_REPOSITORY_API . '?cmd=ping');
if ($data === false) {
$this->hasAccess = false;
throw new Exception('repo_error');
} elseif ($data !== '1') {
$this->hasAccess = false;
throw new Exception('repo_badresponse');
} else {
$this->hasAccess = true;
}
return $this->hasAccess;
}
/**
* Fetch the data for multiple extensions from the repository
*
* @param string[] $ids A list of extension ids
* @throws Exception
*/
protected function fetchExtensions($ids)
{
if (!$this->checkAccess()) return;
$httpclient = new DokuHTTPClient();
$data = [
'fmt' => 'json',
'ext' => $ids
];
$response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $data);
if ($response === false) {
$this->hasAccess = false;
throw new Exception('repo_error');
}
try {
$found = [];
$extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
foreach ($extensions as $extension) {
$this->storeCache($extension['plugin'], $extension);
$found[] = $extension['plugin'];
}
// extensions that have not been returned are not in the repository, but we should cache that too
foreach (array_diff($ids, $found) as $id) {
$this->storeCache($id, []);
}
} catch (JsonException $e) {
$this->hasAccess = false;
throw new Exception('repo_badresponse', 0, $e);
}
}
/**
* This creates a list of Extension objects from the given list of ids
*
* The extensions are initialized by fetching their data from the cache or the repository.
* This is the recommended way to initialize a whole bunch of extensions at once as it will only do
* a single API request for all extensions that are not in the cache.
*
* Extensions that are not found in the cache or the repository will be initialized as null.
*
* @param string[] $ids
* @return (Extension|null)[] [id => Extension|null, ...]
* @throws Exception
*/
public function initExtensions($ids)
{
$result = [];
$toload = [];
// first get all that are cached
foreach ($ids as $id) {
$data = $this->retrieveCache($id);
if ($data === null || $data === []) {
$toload[] = $id;
} else {
$result[$id] = Extension::createFromRemoteData($data);
}
}
// then fetch the rest at once
if ($toload) {
$this->fetchExtensions($toload);
foreach ($toload as $id) {
$data = $this->retrieveCache($id);
if ($data === null || $data === []) {
$result[$id] = null;
} else {
$result[$id] = Extension::createFromRemoteData($data);
}
}
}
return $result;
}
/**
* Initialize a new Extension object from remote data for the given id
*
* @param string $id
* @return Extension|null
* @throws Exception
*/
public function initExtension($id)
{
$result = $this->initExtensions([$id]);
return $result[$id];
}
/**
* Get the pure API data for a single extension
*
* Used when lazy loading remote data in Extension
*
* @param string $id
* @return array|null
* @throws Exception
*/
public function getExtensionData($id)
{
$data = $this->retrieveCache($id);
if ($data === null) {
$this->fetchExtensions([$id]);
$data = $this->retrieveCache($id);
}
return $data;
}
/**
* Search for extensions using the given query string
*
* @param string $q the query string
* @return Extension[] a list of matching extensions
* @throws Exception
*/
public function searchExtensions($q)
{
if (!$this->checkAccess()) return [];
$query = $this->parseQuery($q);
$query['fmt'] = 'json';
$httpclient = new DokuHTTPClient();
$response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query);
if ($response === false) {
$this->hasAccess = false;
throw new Exception('repo_error');
}
try {
$items = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->hasAccess = false;
throw new Exception('repo_badresponse', 0, $e);
}
$results = [];
foreach ($items as $item) {
$this->storeCache($item['plugin'], $item);
$results[] = Extension::createFromRemoteData($item);
}
return $results;
}
/**
* Parses special queries from the query string
*
* @param string $q
* @return array
*/
protected function parseQuery($q)
{
$parameters = [
'tag' => [],
'mail' => [],
'type' => 0,
'ext' => []
];
// extract tags
if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$q = str_replace($m[2], '', $q);
$parameters['tag'][] = $m[3];
}
}
// extract author ids
if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$q = str_replace($m[2], '', $q);
$parameters['mail'][] = $m[3];
}
}
// extract extensions
if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
foreach ($matches as $m) {
$q = str_replace($m[2], '', $q);
$parameters['ext'][] = $m[3];
}
}
// extract types
if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
$typevalues = array_flip(Extension::COMPONENT_TYPES);
$typevalues = array_change_key_case($typevalues, CASE_LOWER);
foreach ($matches as $m) {
$q = str_replace($m[2], '', $q);
$t = strtolower($m[3]);
if (isset($typevalues[$t])) {
$parameters['type'] += $typevalues[$t];
}
}
}
$parameters['q'] = trim($q);
return $parameters;
}
/**
* Store the data for a single extension in the cache
*
* @param string $id
* @param array $data
*/
protected function storeCache($id, $data)
{
$cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
$cache->storeCache(serialize($data));
}
/**
* Retrieve the data for a single extension from the cache
*
* @param string $id
* @return array|null the data or null if not in cache
*/
protected function retrieveCache($id)
{
$cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
if ($cache->useCache(['age' => self::CACHE_TIME])) {
return unserialize($cache->retrieveCache(false));
}
return null;
}
}