1cf2dcf1bSAndreas Gohr<?php 2cf2dcf1bSAndreas Gohr 3cf2dcf1bSAndreas Gohrnamespace dokuwiki\plugin\extension; 4cf2dcf1bSAndreas Gohr 5cf2dcf1bSAndreas Gohruse dokuwiki\Cache\Cache; 6cf2dcf1bSAndreas Gohruse dokuwiki\plugin\upgrade\HTTP\DokuHTTPClient; 7cf2dcf1bSAndreas Gohruse JsonException; 8cf2dcf1bSAndreas Gohr 9cf2dcf1bSAndreas Gohrclass Repository 10cf2dcf1bSAndreas Gohr{ 11cf2dcf1bSAndreas Gohr public const EXTENSION_REPOSITORY_API = 'https://www.dokuwiki.org/lib/plugins/pluginrepo/api.php'; 12cf2dcf1bSAndreas Gohr 13cf2dcf1bSAndreas Gohr protected const CACHE_PREFIX = '##extension_manager##'; 14cf2dcf1bSAndreas Gohr protected const CACHE_SUFFIX = '.repo'; 15cf2dcf1bSAndreas Gohr protected const CACHE_TIME = 3600 * 24; 16cf2dcf1bSAndreas Gohr 17cf2dcf1bSAndreas Gohr protected static $instance; 18cf2dcf1bSAndreas Gohr protected $hasAccess; 19cf2dcf1bSAndreas Gohr 20cf2dcf1bSAndreas Gohr /** 21cf2dcf1bSAndreas Gohr * 22cf2dcf1bSAndreas Gohr */ 23cf2dcf1bSAndreas Gohr protected function __construct() 24cf2dcf1bSAndreas Gohr { 25cf2dcf1bSAndreas Gohr } 26cf2dcf1bSAndreas Gohr 27cf2dcf1bSAndreas Gohr /** 28cf2dcf1bSAndreas Gohr * @return Repository 29cf2dcf1bSAndreas Gohr */ 30cf2dcf1bSAndreas Gohr public static function getInstance() 31cf2dcf1bSAndreas Gohr { 32cf2dcf1bSAndreas Gohr if (self::$instance === null) { 33cf2dcf1bSAndreas Gohr self::$instance = new self(); 34cf2dcf1bSAndreas Gohr } 35cf2dcf1bSAndreas Gohr return self::$instance; 36cf2dcf1bSAndreas Gohr } 37cf2dcf1bSAndreas Gohr 38cf2dcf1bSAndreas Gohr /** 39cf2dcf1bSAndreas Gohr * Check if access to the repository is possible 40cf2dcf1bSAndreas Gohr * 41cf2dcf1bSAndreas Gohr * On the first call this will throw an exception if access is not possible. On subsequent calls 42cf2dcf1bSAndreas Gohr * it will return the cached result. Thus it is recommended to call this method once when instantiating 43cf2dcf1bSAndreas Gohr * the repository for the first time and handle the exception there. Subsequent calls can then be used 44cf2dcf1bSAndreas Gohr * to access cached data. 45cf2dcf1bSAndreas Gohr * 46cf2dcf1bSAndreas Gohr * @return bool 47cf2dcf1bSAndreas Gohr * @throws Exception 48cf2dcf1bSAndreas Gohr */ 49cf2dcf1bSAndreas Gohr public function checkAccess() 50cf2dcf1bSAndreas Gohr { 51cf2dcf1bSAndreas Gohr if ($this->hasAccess !== null) { 52cf2dcf1bSAndreas Gohr return $this->hasAccess; // we already checked 53cf2dcf1bSAndreas Gohr } 54cf2dcf1bSAndreas Gohr 55cf2dcf1bSAndreas Gohr // check for SSL support 56cf2dcf1bSAndreas Gohr if (!in_array('ssl', stream_get_transports())) { 57cf2dcf1bSAndreas Gohr throw new Exception('nossl'); 58cf2dcf1bSAndreas Gohr } 59cf2dcf1bSAndreas Gohr 60cf2dcf1bSAndreas Gohr // ping the API 61cf2dcf1bSAndreas Gohr $httpclient = new DokuHTTPClient(); 62cf2dcf1bSAndreas Gohr $httpclient->timeout = 5; 63cf2dcf1bSAndreas Gohr $data = $httpclient->get(self::EXTENSION_REPOSITORY_API . '?cmd=ping'); 64cf2dcf1bSAndreas Gohr if ($data === false) { 65cf2dcf1bSAndreas Gohr $this->hasAccess = false; 66cf2dcf1bSAndreas Gohr throw new Exception('repo_error'); 67cf2dcf1bSAndreas Gohr } elseif ($data !== '1') { 68cf2dcf1bSAndreas Gohr $this->hasAccess = false; 69cf2dcf1bSAndreas Gohr throw new Exception('repo_badresponse'); 70cf2dcf1bSAndreas Gohr } else { 71cf2dcf1bSAndreas Gohr $this->hasAccess = true; 72cf2dcf1bSAndreas Gohr } 73cf2dcf1bSAndreas Gohr return $this->hasAccess; 74cf2dcf1bSAndreas Gohr } 75cf2dcf1bSAndreas Gohr 76cf2dcf1bSAndreas Gohr /** 77cf2dcf1bSAndreas Gohr * Fetch the data for multiple extensions from the repository 78cf2dcf1bSAndreas Gohr * 79cf2dcf1bSAndreas Gohr * @param string[] $ids A list of extension ids 80cf2dcf1bSAndreas Gohr * @throws Exception 81cf2dcf1bSAndreas Gohr */ 82cf2dcf1bSAndreas Gohr protected function fetchExtensions($ids) 83cf2dcf1bSAndreas Gohr { 84cf2dcf1bSAndreas Gohr if (!$this->checkAccess()) return; 85cf2dcf1bSAndreas Gohr 86cf2dcf1bSAndreas Gohr $httpclient = new DokuHTTPClient(); 87cf2dcf1bSAndreas Gohr $data = [ 88cf2dcf1bSAndreas Gohr 'fmt' => 'json', 89cf2dcf1bSAndreas Gohr 'ext' => $ids 90cf2dcf1bSAndreas Gohr ]; 91cf2dcf1bSAndreas Gohr 92cf2dcf1bSAndreas Gohr $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $data); 93cf2dcf1bSAndreas Gohr if ($response === false) { 94cf2dcf1bSAndreas Gohr $this->hasAccess = false; 95cf2dcf1bSAndreas Gohr throw new Exception('repo_error'); 96cf2dcf1bSAndreas Gohr } 97cf2dcf1bSAndreas Gohr 98cf2dcf1bSAndreas Gohr try { 99*981e70caSAndreas Gohr $found = []; 100cf2dcf1bSAndreas Gohr $extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR); 101cf2dcf1bSAndreas Gohr foreach ($extensions as $extension) { 102cf2dcf1bSAndreas Gohr $this->storeCache($extension['plugin'], $extension); 103*981e70caSAndreas Gohr $found[] = $extension['plugin']; 104cf2dcf1bSAndreas Gohr } 105*981e70caSAndreas Gohr // extensions that have not been returned are not in the repository, but we should cache that too 106*981e70caSAndreas Gohr foreach (array_diff($ids, $found) as $id) { 107*981e70caSAndreas Gohr $this->storeCache($id, []); 108*981e70caSAndreas Gohr } 109*981e70caSAndreas Gohr } catch (JsonException $e) { 110cf2dcf1bSAndreas Gohr $this->hasAccess = false; 111cf2dcf1bSAndreas Gohr throw new Exception('repo_badresponse', 0, $e); 112cf2dcf1bSAndreas Gohr } 113cf2dcf1bSAndreas Gohr } 114cf2dcf1bSAndreas Gohr 115cf2dcf1bSAndreas Gohr /** 116cf2dcf1bSAndreas Gohr * This creates a list of Extension objects from the given list of ids 117cf2dcf1bSAndreas Gohr * 118cf2dcf1bSAndreas Gohr * The extensions are initialized by fetching their data from the cache or the repository. 119cf2dcf1bSAndreas Gohr * This is the recommended way to initialize a whole bunch of extensions at once as it will only do 120cf2dcf1bSAndreas Gohr * a single API request for all extensions that are not in the cache. 121cf2dcf1bSAndreas Gohr * 122cf2dcf1bSAndreas Gohr * Extensions that are not found in the cache or the repository will be initialized as null. 123cf2dcf1bSAndreas Gohr * 124cf2dcf1bSAndreas Gohr * @param string[] $ids 125cf2dcf1bSAndreas Gohr * @return (Extension|null)[] [id => Extension|null, ...] 126cf2dcf1bSAndreas Gohr * @throws Exception 127cf2dcf1bSAndreas Gohr */ 128cf2dcf1bSAndreas Gohr public function initExtensions($ids) 129cf2dcf1bSAndreas Gohr { 130cf2dcf1bSAndreas Gohr $result = []; 131cf2dcf1bSAndreas Gohr $toload = []; 132cf2dcf1bSAndreas Gohr 133cf2dcf1bSAndreas Gohr // first get all that are cached 134cf2dcf1bSAndreas Gohr foreach ($ids as $id) { 135cf2dcf1bSAndreas Gohr $data = $this->retrieveCache($id); 136cf2dcf1bSAndreas Gohr if ($data === null) { 137cf2dcf1bSAndreas Gohr $toload[] = $id; 138cf2dcf1bSAndreas Gohr } else { 139cf2dcf1bSAndreas Gohr $result[$id] = Extension::createFromRemoteData($data); 140cf2dcf1bSAndreas Gohr } 141cf2dcf1bSAndreas Gohr } 142cf2dcf1bSAndreas Gohr 143cf2dcf1bSAndreas Gohr // then fetch the rest at once 144cf2dcf1bSAndreas Gohr if ($toload) { 145cf2dcf1bSAndreas Gohr $this->fetchExtensions($toload); 146cf2dcf1bSAndreas Gohr foreach ($toload as $id) { 147cf2dcf1bSAndreas Gohr $data = $this->retrieveCache($id); 148cf2dcf1bSAndreas Gohr if ($data === null) { 149cf2dcf1bSAndreas Gohr $result[$id] = null; 150cf2dcf1bSAndreas Gohr } else { 151cf2dcf1bSAndreas Gohr $result[$id] = Extension::createFromRemoteData($data); 152cf2dcf1bSAndreas Gohr } 153cf2dcf1bSAndreas Gohr } 154cf2dcf1bSAndreas Gohr } 155cf2dcf1bSAndreas Gohr 156cf2dcf1bSAndreas Gohr return $result; 157cf2dcf1bSAndreas Gohr } 158cf2dcf1bSAndreas Gohr 159cf2dcf1bSAndreas Gohr /** 160cf2dcf1bSAndreas Gohr * Initialize a new Extension object from remote data for the given id 161cf2dcf1bSAndreas Gohr * 162cf2dcf1bSAndreas Gohr * @param string $id 163cf2dcf1bSAndreas Gohr * @return Extension|null 164cf2dcf1bSAndreas Gohr * @throws Exception 165cf2dcf1bSAndreas Gohr */ 166cf2dcf1bSAndreas Gohr public function initExtension($id) 167cf2dcf1bSAndreas Gohr { 168cf2dcf1bSAndreas Gohr $result = $this->initExtensions([$id]); 169cf2dcf1bSAndreas Gohr return $result[$id]; 170cf2dcf1bSAndreas Gohr } 171cf2dcf1bSAndreas Gohr 172cf2dcf1bSAndreas Gohr /** 173cf2dcf1bSAndreas Gohr * Get the pure API data for a single extension 174cf2dcf1bSAndreas Gohr * 175cf2dcf1bSAndreas Gohr * Used when lazy loading remote data in Extension 176cf2dcf1bSAndreas Gohr * 177cf2dcf1bSAndreas Gohr * @param string $id 178cf2dcf1bSAndreas Gohr * @return array|null 179cf2dcf1bSAndreas Gohr * @throws Exception 180cf2dcf1bSAndreas Gohr */ 181cf2dcf1bSAndreas Gohr public function getExtensionData($id) 182cf2dcf1bSAndreas Gohr { 183cf2dcf1bSAndreas Gohr $data = $this->retrieveCache($id); 184cf2dcf1bSAndreas Gohr if ($data === null) { 185cf2dcf1bSAndreas Gohr $this->fetchExtensions([$id]); 186cf2dcf1bSAndreas Gohr $data = $this->retrieveCache($id); 187cf2dcf1bSAndreas Gohr } 188cf2dcf1bSAndreas Gohr return $data; 189cf2dcf1bSAndreas Gohr } 190cf2dcf1bSAndreas Gohr 191cf2dcf1bSAndreas Gohr /** 192cf2dcf1bSAndreas Gohr * Search for extensions using the given query string 193cf2dcf1bSAndreas Gohr * 194cf2dcf1bSAndreas Gohr * @param string $q the query string 195cf2dcf1bSAndreas Gohr * @return Extension[] a list of matching extensions 196cf2dcf1bSAndreas Gohr * @throws Exception 197cf2dcf1bSAndreas Gohr */ 198cf2dcf1bSAndreas Gohr public function searchExtensions($q) 199cf2dcf1bSAndreas Gohr { 200cf2dcf1bSAndreas Gohr if (!$this->checkAccess()) return []; 201cf2dcf1bSAndreas Gohr 202cf2dcf1bSAndreas Gohr $query = $this->parseQuery($q); 203cf2dcf1bSAndreas Gohr $query['fmt'] = 'json'; 204cf2dcf1bSAndreas Gohr 205cf2dcf1bSAndreas Gohr $httpclient = new DokuHTTPClient(); 206cf2dcf1bSAndreas Gohr $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query); 207cf2dcf1bSAndreas Gohr if ($response === false) { 208cf2dcf1bSAndreas Gohr $this->hasAccess = false; 209cf2dcf1bSAndreas Gohr throw new Exception('repo_error'); 210cf2dcf1bSAndreas Gohr } 211cf2dcf1bSAndreas Gohr 212cf2dcf1bSAndreas Gohr try { 213cf2dcf1bSAndreas Gohr $items = json_decode($response, true, 512, JSON_THROW_ON_ERROR); 214cf2dcf1bSAndreas Gohr } catch (JsonException $e) { 215cf2dcf1bSAndreas Gohr $this->hasAccess = false; 216cf2dcf1bSAndreas Gohr throw new Exception('repo_badresponse', 0, $e); 217cf2dcf1bSAndreas Gohr } 218cf2dcf1bSAndreas Gohr 219cf2dcf1bSAndreas Gohr $results = []; 220cf2dcf1bSAndreas Gohr foreach ($items as $item) { 221cf2dcf1bSAndreas Gohr $this->storeCache($item['plugin'], $item); 222cf2dcf1bSAndreas Gohr $results[] = Extension::createFromRemoteData($item); 223cf2dcf1bSAndreas Gohr } 224cf2dcf1bSAndreas Gohr return $results; 225cf2dcf1bSAndreas Gohr } 226cf2dcf1bSAndreas Gohr 227cf2dcf1bSAndreas Gohr /** 228cf2dcf1bSAndreas Gohr * Parses special queries from the query string 229cf2dcf1bSAndreas Gohr * 230cf2dcf1bSAndreas Gohr * @param string $q 231cf2dcf1bSAndreas Gohr * @return array 232cf2dcf1bSAndreas Gohr */ 233cf2dcf1bSAndreas Gohr protected function parseQuery($q) 234cf2dcf1bSAndreas Gohr { 235cf2dcf1bSAndreas Gohr $parameters = [ 236cf2dcf1bSAndreas Gohr 'tag' => [], 237cf2dcf1bSAndreas Gohr 'mail' => [], 238cf2dcf1bSAndreas Gohr 'type' => [], 239cf2dcf1bSAndreas Gohr 'ext' => [] 240cf2dcf1bSAndreas Gohr ]; 241cf2dcf1bSAndreas Gohr 242cf2dcf1bSAndreas Gohr // extract tags 243cf2dcf1bSAndreas Gohr if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 244cf2dcf1bSAndreas Gohr foreach ($matches as $m) { 245cf2dcf1bSAndreas Gohr $q = str_replace($m[2], '', $q); 246cf2dcf1bSAndreas Gohr $parameters['tag'][] = $m[3]; 247cf2dcf1bSAndreas Gohr } 248cf2dcf1bSAndreas Gohr } 249cf2dcf1bSAndreas Gohr // extract author ids 250cf2dcf1bSAndreas Gohr if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 251cf2dcf1bSAndreas Gohr foreach ($matches as $m) { 252cf2dcf1bSAndreas Gohr $q = str_replace($m[2], '', $q); 253cf2dcf1bSAndreas Gohr $parameters['mail'][] = $m[3]; 254cf2dcf1bSAndreas Gohr } 255cf2dcf1bSAndreas Gohr } 256cf2dcf1bSAndreas Gohr // extract extensions 257cf2dcf1bSAndreas Gohr if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 258cf2dcf1bSAndreas Gohr foreach ($matches as $m) { 259cf2dcf1bSAndreas Gohr $q = str_replace($m[2], '', $q); 260cf2dcf1bSAndreas Gohr $parameters['ext'][] = $m[3]; 261cf2dcf1bSAndreas Gohr } 262cf2dcf1bSAndreas Gohr } 263cf2dcf1bSAndreas Gohr // extract types 264cf2dcf1bSAndreas Gohr if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 265cf2dcf1bSAndreas Gohr foreach ($matches as $m) { 266cf2dcf1bSAndreas Gohr $q = str_replace($m[2], '', $q); 267cf2dcf1bSAndreas Gohr $parameters['type'][] = $m[3]; 268cf2dcf1bSAndreas Gohr } 269cf2dcf1bSAndreas Gohr } 270cf2dcf1bSAndreas Gohr 271cf2dcf1bSAndreas Gohr // FIXME make integer from type value 272cf2dcf1bSAndreas Gohr 273cf2dcf1bSAndreas Gohr $parameters['q'] = trim($q); 274cf2dcf1bSAndreas Gohr return $parameters; 275cf2dcf1bSAndreas Gohr } 276cf2dcf1bSAndreas Gohr 277cf2dcf1bSAndreas Gohr 278cf2dcf1bSAndreas Gohr /** 279cf2dcf1bSAndreas Gohr * Store the data for a single extension in the cache 280cf2dcf1bSAndreas Gohr * 281cf2dcf1bSAndreas Gohr * @param string $id 282cf2dcf1bSAndreas Gohr * @param array $data 283cf2dcf1bSAndreas Gohr */ 284cf2dcf1bSAndreas Gohr protected function storeCache($id, $data) 285cf2dcf1bSAndreas Gohr { 286cf2dcf1bSAndreas Gohr $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); 287cf2dcf1bSAndreas Gohr $cache->storeCache(serialize($data)); 288cf2dcf1bSAndreas Gohr } 289cf2dcf1bSAndreas Gohr 290cf2dcf1bSAndreas Gohr /** 291cf2dcf1bSAndreas Gohr * Retrieve the data for a single extension from the cache 292cf2dcf1bSAndreas Gohr * 293cf2dcf1bSAndreas Gohr * @param string $id 294cf2dcf1bSAndreas Gohr * @return array|null the data or null if not in cache 295cf2dcf1bSAndreas Gohr */ 296cf2dcf1bSAndreas Gohr protected function retrieveCache($id) 297cf2dcf1bSAndreas Gohr { 298cf2dcf1bSAndreas Gohr $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); 299cf2dcf1bSAndreas Gohr if ($cache->useCache(['age' => self::CACHE_TIME])) { 300cf2dcf1bSAndreas Gohr return unserialize($cache->retrieveCache(false)); 301cf2dcf1bSAndreas Gohr } 302cf2dcf1bSAndreas Gohr return null; 303cf2dcf1bSAndreas Gohr } 304cf2dcf1bSAndreas Gohr} 305