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