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