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