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