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