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