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 $found = []; 100 $extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR); 101 foreach ($extensions as $extension) { 102 $this->storeCache($extension['plugin'], $extension); 103 $found[] = $extension['plugin']; 104 } 105 // extensions that have not been returned are not in the repository, but we should cache that too 106 foreach (array_diff($ids, $found) as $id) { 107 $this->storeCache($id, []); 108 } 109 } catch (JsonException $e) { 110 $this->hasAccess = false; 111 throw new Exception('repo_badresponse', 0, $e); 112 } 113 } 114 115 /** 116 * This creates a list of Extension objects from the given list of ids 117 * 118 * The extensions are initialized by fetching their data from the cache or the repository. 119 * This is the recommended way to initialize a whole bunch of extensions at once as it will only do 120 * a single API request for all extensions that are not in the cache. 121 * 122 * Extensions that are not found in the cache or the repository will be initialized as null. 123 * 124 * @param string[] $ids 125 * @return (Extension|null)[] [id => Extension|null, ...] 126 * @throws Exception 127 */ 128 public function initExtensions($ids) 129 { 130 $result = []; 131 $toload = []; 132 133 // first get all that are cached 134 foreach ($ids as $id) { 135 $data = $this->retrieveCache($id); 136 if ($data === null) { 137 $toload[] = $id; 138 } else { 139 $result[$id] = Extension::createFromRemoteData($data); 140 } 141 } 142 143 // then fetch the rest at once 144 if ($toload) { 145 $this->fetchExtensions($toload); 146 foreach ($toload as $id) { 147 $data = $this->retrieveCache($id); 148 if ($data === null) { 149 $result[$id] = null; 150 } else { 151 $result[$id] = Extension::createFromRemoteData($data); 152 } 153 } 154 } 155 156 return $result; 157 } 158 159 /** 160 * Initialize a new Extension object from remote data for the given id 161 * 162 * @param string $id 163 * @return Extension|null 164 * @throws Exception 165 */ 166 public function initExtension($id) 167 { 168 $result = $this->initExtensions([$id]); 169 return $result[$id]; 170 } 171 172 /** 173 * Get the pure API data for a single extension 174 * 175 * Used when lazy loading remote data in Extension 176 * 177 * @param string $id 178 * @return array|null 179 * @throws Exception 180 */ 181 public function getExtensionData($id) 182 { 183 $data = $this->retrieveCache($id); 184 if ($data === null) { 185 $this->fetchExtensions([$id]); 186 $data = $this->retrieveCache($id); 187 } 188 return $data; 189 } 190 191 /** 192 * Search for extensions using the given query string 193 * 194 * @param string $q the query string 195 * @return Extension[] a list of matching extensions 196 * @throws Exception 197 */ 198 public function searchExtensions($q) 199 { 200 if (!$this->checkAccess()) return []; 201 202 $query = $this->parseQuery($q); 203 $query['fmt'] = 'json'; 204 205 $httpclient = new DokuHTTPClient(); 206 $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query); 207 if ($response === false) { 208 $this->hasAccess = false; 209 throw new Exception('repo_error'); 210 } 211 212 try { 213 $items = json_decode($response, true, 512, JSON_THROW_ON_ERROR); 214 } catch (JsonException $e) { 215 $this->hasAccess = false; 216 throw new Exception('repo_badresponse', 0, $e); 217 } 218 219 $results = []; 220 foreach ($items as $item) { 221 $this->storeCache($item['plugin'], $item); 222 $results[] = Extension::createFromRemoteData($item); 223 } 224 return $results; 225 } 226 227 /** 228 * Parses special queries from the query string 229 * 230 * @param string $q 231 * @return array 232 */ 233 protected function parseQuery($q) 234 { 235 $parameters = [ 236 'tag' => [], 237 'mail' => [], 238 'type' => [], 239 'ext' => [] 240 ]; 241 242 // extract tags 243 if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 244 foreach ($matches as $m) { 245 $q = str_replace($m[2], '', $q); 246 $parameters['tag'][] = $m[3]; 247 } 248 } 249 // extract author ids 250 if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 251 foreach ($matches as $m) { 252 $q = str_replace($m[2], '', $q); 253 $parameters['mail'][] = $m[3]; 254 } 255 } 256 // extract extensions 257 if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 258 foreach ($matches as $m) { 259 $q = str_replace($m[2], '', $q); 260 $parameters['ext'][] = $m[3]; 261 } 262 } 263 // extract types 264 if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) { 265 foreach ($matches as $m) { 266 $q = str_replace($m[2], '', $q); 267 $parameters['type'][] = $m[3]; 268 } 269 } 270 271 // FIXME make integer from type value 272 273 $parameters['q'] = trim($q); 274 return $parameters; 275 } 276 277 278 /** 279 * Store the data for a single extension in the cache 280 * 281 * @param string $id 282 * @param array $data 283 */ 284 protected function storeCache($id, $data) 285 { 286 $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); 287 $cache->storeCache(serialize($data)); 288 } 289 290 /** 291 * Retrieve the data for a single extension from the cache 292 * 293 * @param string $id 294 * @return array|null the data or null if not in cache 295 */ 296 protected function retrieveCache($id) 297 { 298 $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX); 299 if ($cache->useCache(['age' => self::CACHE_TIME])) { 300 return unserialize($cache->retrieveCache(false)); 301 } 302 return null; 303 } 304} 305