xref: /dokuwiki/lib/plugins/extension/Repository.php (revision 093fe67e98c0cdb4b73fd46938e49b64971483c2)
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