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