xref: /dokuwiki/lib/plugins/extension/Repository.php (revision 981e70cabab67fd2c1ea32d1f7d1ee51fe3e16a6)
1cf2dcf1bSAndreas Gohr<?php
2cf2dcf1bSAndreas Gohr
3cf2dcf1bSAndreas Gohrnamespace dokuwiki\plugin\extension;
4cf2dcf1bSAndreas Gohr
5cf2dcf1bSAndreas Gohruse dokuwiki\Cache\Cache;
6cf2dcf1bSAndreas Gohruse dokuwiki\plugin\upgrade\HTTP\DokuHTTPClient;
7cf2dcf1bSAndreas Gohruse JsonException;
8cf2dcf1bSAndreas Gohr
9cf2dcf1bSAndreas Gohrclass Repository
10cf2dcf1bSAndreas Gohr{
11cf2dcf1bSAndreas Gohr    public const EXTENSION_REPOSITORY_API = 'https://www.dokuwiki.org/lib/plugins/pluginrepo/api.php';
12cf2dcf1bSAndreas Gohr
13cf2dcf1bSAndreas Gohr    protected const CACHE_PREFIX = '##extension_manager##';
14cf2dcf1bSAndreas Gohr    protected const CACHE_SUFFIX = '.repo';
15cf2dcf1bSAndreas Gohr    protected const CACHE_TIME = 3600 * 24;
16cf2dcf1bSAndreas Gohr
17cf2dcf1bSAndreas Gohr    protected static $instance;
18cf2dcf1bSAndreas Gohr    protected $hasAccess;
19cf2dcf1bSAndreas Gohr
20cf2dcf1bSAndreas Gohr    /**
21cf2dcf1bSAndreas Gohr     *
22cf2dcf1bSAndreas Gohr     */
23cf2dcf1bSAndreas Gohr    protected function __construct()
24cf2dcf1bSAndreas Gohr    {
25cf2dcf1bSAndreas Gohr    }
26cf2dcf1bSAndreas Gohr
27cf2dcf1bSAndreas Gohr    /**
28cf2dcf1bSAndreas Gohr     * @return Repository
29cf2dcf1bSAndreas Gohr     */
30cf2dcf1bSAndreas Gohr    public static function getInstance()
31cf2dcf1bSAndreas Gohr    {
32cf2dcf1bSAndreas Gohr        if (self::$instance === null) {
33cf2dcf1bSAndreas Gohr            self::$instance = new self();
34cf2dcf1bSAndreas Gohr        }
35cf2dcf1bSAndreas Gohr        return self::$instance;
36cf2dcf1bSAndreas Gohr    }
37cf2dcf1bSAndreas Gohr
38cf2dcf1bSAndreas Gohr    /**
39cf2dcf1bSAndreas Gohr     * Check if access to the repository is possible
40cf2dcf1bSAndreas Gohr     *
41cf2dcf1bSAndreas Gohr     * On the first call this will throw an exception if access is not possible. On subsequent calls
42cf2dcf1bSAndreas Gohr     * it will return the cached result. Thus it is recommended to call this method once when instantiating
43cf2dcf1bSAndreas Gohr     * the repository for the first time and handle the exception there. Subsequent calls can then be used
44cf2dcf1bSAndreas Gohr     * to access cached data.
45cf2dcf1bSAndreas Gohr     *
46cf2dcf1bSAndreas Gohr     * @return bool
47cf2dcf1bSAndreas Gohr     * @throws Exception
48cf2dcf1bSAndreas Gohr     */
49cf2dcf1bSAndreas Gohr    public function checkAccess()
50cf2dcf1bSAndreas Gohr    {
51cf2dcf1bSAndreas Gohr        if ($this->hasAccess !== null) {
52cf2dcf1bSAndreas Gohr            return $this->hasAccess; // we already checked
53cf2dcf1bSAndreas Gohr        }
54cf2dcf1bSAndreas Gohr
55cf2dcf1bSAndreas Gohr        // check for SSL support
56cf2dcf1bSAndreas Gohr        if (!in_array('ssl', stream_get_transports())) {
57cf2dcf1bSAndreas Gohr            throw new Exception('nossl');
58cf2dcf1bSAndreas Gohr        }
59cf2dcf1bSAndreas Gohr
60cf2dcf1bSAndreas Gohr        // ping the API
61cf2dcf1bSAndreas Gohr        $httpclient = new DokuHTTPClient();
62cf2dcf1bSAndreas Gohr        $httpclient->timeout = 5;
63cf2dcf1bSAndreas Gohr        $data = $httpclient->get(self::EXTENSION_REPOSITORY_API . '?cmd=ping');
64cf2dcf1bSAndreas Gohr        if ($data === false) {
65cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
66cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
67cf2dcf1bSAndreas Gohr        } elseif ($data !== '1') {
68cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
69cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse');
70cf2dcf1bSAndreas Gohr        } else {
71cf2dcf1bSAndreas Gohr            $this->hasAccess = true;
72cf2dcf1bSAndreas Gohr        }
73cf2dcf1bSAndreas Gohr        return $this->hasAccess;
74cf2dcf1bSAndreas Gohr    }
75cf2dcf1bSAndreas Gohr
76cf2dcf1bSAndreas Gohr    /**
77cf2dcf1bSAndreas Gohr     * Fetch the data for multiple extensions from the repository
78cf2dcf1bSAndreas Gohr     *
79cf2dcf1bSAndreas Gohr     * @param string[] $ids A list of extension ids
80cf2dcf1bSAndreas Gohr     * @throws Exception
81cf2dcf1bSAndreas Gohr     */
82cf2dcf1bSAndreas Gohr    protected function fetchExtensions($ids)
83cf2dcf1bSAndreas Gohr    {
84cf2dcf1bSAndreas Gohr        if (!$this->checkAccess()) return;
85cf2dcf1bSAndreas Gohr
86cf2dcf1bSAndreas Gohr        $httpclient = new DokuHTTPClient();
87cf2dcf1bSAndreas Gohr        $data = [
88cf2dcf1bSAndreas Gohr            'fmt' => 'json',
89cf2dcf1bSAndreas Gohr            'ext' => $ids
90cf2dcf1bSAndreas Gohr        ];
91cf2dcf1bSAndreas Gohr
92cf2dcf1bSAndreas Gohr        $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $data);
93cf2dcf1bSAndreas Gohr        if ($response === false) {
94cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
95cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
96cf2dcf1bSAndreas Gohr        }
97cf2dcf1bSAndreas Gohr
98cf2dcf1bSAndreas Gohr        try {
99*981e70caSAndreas Gohr            $found = [];
100cf2dcf1bSAndreas Gohr            $extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
101cf2dcf1bSAndreas Gohr            foreach ($extensions as $extension) {
102cf2dcf1bSAndreas Gohr                $this->storeCache($extension['plugin'], $extension);
103*981e70caSAndreas Gohr                $found[] = $extension['plugin'];
104cf2dcf1bSAndreas Gohr            }
105*981e70caSAndreas Gohr            // extensions that have not been returned are not in the repository, but we should cache that too
106*981e70caSAndreas Gohr            foreach (array_diff($ids, $found) as $id) {
107*981e70caSAndreas Gohr                $this->storeCache($id, []);
108*981e70caSAndreas Gohr            }
109*981e70caSAndreas Gohr        } catch (JsonException $e) {
110cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
111cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse', 0, $e);
112cf2dcf1bSAndreas Gohr        }
113cf2dcf1bSAndreas Gohr    }
114cf2dcf1bSAndreas Gohr
115cf2dcf1bSAndreas Gohr    /**
116cf2dcf1bSAndreas Gohr     * This creates a list of Extension objects from the given list of ids
117cf2dcf1bSAndreas Gohr     *
118cf2dcf1bSAndreas Gohr     * The extensions are initialized by fetching their data from the cache or the repository.
119cf2dcf1bSAndreas Gohr     * This is the recommended way to initialize a whole bunch of extensions at once as it will only do
120cf2dcf1bSAndreas Gohr     * a single API request for all extensions that are not in the cache.
121cf2dcf1bSAndreas Gohr     *
122cf2dcf1bSAndreas Gohr     * Extensions that are not found in the cache or the repository will be initialized as null.
123cf2dcf1bSAndreas Gohr     *
124cf2dcf1bSAndreas Gohr     * @param string[] $ids
125cf2dcf1bSAndreas Gohr     * @return (Extension|null)[] [id => Extension|null, ...]
126cf2dcf1bSAndreas Gohr     * @throws Exception
127cf2dcf1bSAndreas Gohr     */
128cf2dcf1bSAndreas Gohr    public function initExtensions($ids)
129cf2dcf1bSAndreas Gohr    {
130cf2dcf1bSAndreas Gohr        $result = [];
131cf2dcf1bSAndreas Gohr        $toload = [];
132cf2dcf1bSAndreas Gohr
133cf2dcf1bSAndreas Gohr        // first get all that are cached
134cf2dcf1bSAndreas Gohr        foreach ($ids as $id) {
135cf2dcf1bSAndreas Gohr            $data = $this->retrieveCache($id);
136cf2dcf1bSAndreas Gohr            if ($data === null) {
137cf2dcf1bSAndreas Gohr                $toload[] = $id;
138cf2dcf1bSAndreas Gohr            } else {
139cf2dcf1bSAndreas Gohr                $result[$id] = Extension::createFromRemoteData($data);
140cf2dcf1bSAndreas Gohr            }
141cf2dcf1bSAndreas Gohr        }
142cf2dcf1bSAndreas Gohr
143cf2dcf1bSAndreas Gohr        // then fetch the rest at once
144cf2dcf1bSAndreas Gohr        if ($toload) {
145cf2dcf1bSAndreas Gohr            $this->fetchExtensions($toload);
146cf2dcf1bSAndreas Gohr            foreach ($toload as $id) {
147cf2dcf1bSAndreas Gohr                $data = $this->retrieveCache($id);
148cf2dcf1bSAndreas Gohr                if ($data === null) {
149cf2dcf1bSAndreas Gohr                    $result[$id] = null;
150cf2dcf1bSAndreas Gohr                } else {
151cf2dcf1bSAndreas Gohr                    $result[$id] = Extension::createFromRemoteData($data);
152cf2dcf1bSAndreas Gohr                }
153cf2dcf1bSAndreas Gohr            }
154cf2dcf1bSAndreas Gohr        }
155cf2dcf1bSAndreas Gohr
156cf2dcf1bSAndreas Gohr        return $result;
157cf2dcf1bSAndreas Gohr    }
158cf2dcf1bSAndreas Gohr
159cf2dcf1bSAndreas Gohr    /**
160cf2dcf1bSAndreas Gohr     * Initialize a new Extension object from remote data for the given id
161cf2dcf1bSAndreas Gohr     *
162cf2dcf1bSAndreas Gohr     * @param string $id
163cf2dcf1bSAndreas Gohr     * @return Extension|null
164cf2dcf1bSAndreas Gohr     * @throws Exception
165cf2dcf1bSAndreas Gohr     */
166cf2dcf1bSAndreas Gohr    public function initExtension($id)
167cf2dcf1bSAndreas Gohr    {
168cf2dcf1bSAndreas Gohr        $result = $this->initExtensions([$id]);
169cf2dcf1bSAndreas Gohr        return $result[$id];
170cf2dcf1bSAndreas Gohr    }
171cf2dcf1bSAndreas Gohr
172cf2dcf1bSAndreas Gohr    /**
173cf2dcf1bSAndreas Gohr     * Get the pure API data for a single extension
174cf2dcf1bSAndreas Gohr     *
175cf2dcf1bSAndreas Gohr     * Used when lazy loading remote data in Extension
176cf2dcf1bSAndreas Gohr     *
177cf2dcf1bSAndreas Gohr     * @param string $id
178cf2dcf1bSAndreas Gohr     * @return array|null
179cf2dcf1bSAndreas Gohr     * @throws Exception
180cf2dcf1bSAndreas Gohr     */
181cf2dcf1bSAndreas Gohr    public function getExtensionData($id)
182cf2dcf1bSAndreas Gohr    {
183cf2dcf1bSAndreas Gohr        $data = $this->retrieveCache($id);
184cf2dcf1bSAndreas Gohr        if ($data === null) {
185cf2dcf1bSAndreas Gohr            $this->fetchExtensions([$id]);
186cf2dcf1bSAndreas Gohr            $data = $this->retrieveCache($id);
187cf2dcf1bSAndreas Gohr        }
188cf2dcf1bSAndreas Gohr        return $data;
189cf2dcf1bSAndreas Gohr    }
190cf2dcf1bSAndreas Gohr
191cf2dcf1bSAndreas Gohr    /**
192cf2dcf1bSAndreas Gohr     * Search for extensions using the given query string
193cf2dcf1bSAndreas Gohr     *
194cf2dcf1bSAndreas Gohr     * @param string $q the query string
195cf2dcf1bSAndreas Gohr     * @return Extension[] a list of matching extensions
196cf2dcf1bSAndreas Gohr     * @throws Exception
197cf2dcf1bSAndreas Gohr     */
198cf2dcf1bSAndreas Gohr    public function searchExtensions($q)
199cf2dcf1bSAndreas Gohr    {
200cf2dcf1bSAndreas Gohr        if (!$this->checkAccess()) return [];
201cf2dcf1bSAndreas Gohr
202cf2dcf1bSAndreas Gohr        $query = $this->parseQuery($q);
203cf2dcf1bSAndreas Gohr        $query['fmt'] = 'json';
204cf2dcf1bSAndreas Gohr
205cf2dcf1bSAndreas Gohr        $httpclient = new DokuHTTPClient();
206cf2dcf1bSAndreas Gohr        $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query);
207cf2dcf1bSAndreas Gohr        if ($response === false) {
208cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
209cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
210cf2dcf1bSAndreas Gohr        }
211cf2dcf1bSAndreas Gohr
212cf2dcf1bSAndreas Gohr        try {
213cf2dcf1bSAndreas Gohr            $items = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
214cf2dcf1bSAndreas Gohr        } catch (JsonException $e) {
215cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
216cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse', 0, $e);
217cf2dcf1bSAndreas Gohr        }
218cf2dcf1bSAndreas Gohr
219cf2dcf1bSAndreas Gohr        $results = [];
220cf2dcf1bSAndreas Gohr        foreach ($items as $item) {
221cf2dcf1bSAndreas Gohr            $this->storeCache($item['plugin'], $item);
222cf2dcf1bSAndreas Gohr            $results[] = Extension::createFromRemoteData($item);
223cf2dcf1bSAndreas Gohr        }
224cf2dcf1bSAndreas Gohr        return $results;
225cf2dcf1bSAndreas Gohr    }
226cf2dcf1bSAndreas Gohr
227cf2dcf1bSAndreas Gohr    /**
228cf2dcf1bSAndreas Gohr     * Parses special queries from the query string
229cf2dcf1bSAndreas Gohr     *
230cf2dcf1bSAndreas Gohr     * @param string $q
231cf2dcf1bSAndreas Gohr     * @return array
232cf2dcf1bSAndreas Gohr     */
233cf2dcf1bSAndreas Gohr    protected function parseQuery($q)
234cf2dcf1bSAndreas Gohr    {
235cf2dcf1bSAndreas Gohr        $parameters = [
236cf2dcf1bSAndreas Gohr            'tag' => [],
237cf2dcf1bSAndreas Gohr            'mail' => [],
238cf2dcf1bSAndreas Gohr            'type' => [],
239cf2dcf1bSAndreas Gohr            'ext' => []
240cf2dcf1bSAndreas Gohr        ];
241cf2dcf1bSAndreas Gohr
242cf2dcf1bSAndreas Gohr        // extract tags
243cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
244cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
245cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
246cf2dcf1bSAndreas Gohr                $parameters['tag'][] = $m[3];
247cf2dcf1bSAndreas Gohr            }
248cf2dcf1bSAndreas Gohr        }
249cf2dcf1bSAndreas Gohr        // extract author ids
250cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
251cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
252cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
253cf2dcf1bSAndreas Gohr                $parameters['mail'][] = $m[3];
254cf2dcf1bSAndreas Gohr            }
255cf2dcf1bSAndreas Gohr        }
256cf2dcf1bSAndreas Gohr        // extract extensions
257cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
258cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
259cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
260cf2dcf1bSAndreas Gohr                $parameters['ext'][] = $m[3];
261cf2dcf1bSAndreas Gohr            }
262cf2dcf1bSAndreas Gohr        }
263cf2dcf1bSAndreas Gohr        // extract types
264cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
265cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
266cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
267cf2dcf1bSAndreas Gohr                $parameters['type'][] = $m[3];
268cf2dcf1bSAndreas Gohr            }
269cf2dcf1bSAndreas Gohr        }
270cf2dcf1bSAndreas Gohr
271cf2dcf1bSAndreas Gohr        // FIXME make integer from type value
272cf2dcf1bSAndreas Gohr
273cf2dcf1bSAndreas Gohr        $parameters['q'] = trim($q);
274cf2dcf1bSAndreas Gohr        return $parameters;
275cf2dcf1bSAndreas Gohr    }
276cf2dcf1bSAndreas Gohr
277cf2dcf1bSAndreas Gohr
278cf2dcf1bSAndreas Gohr    /**
279cf2dcf1bSAndreas Gohr     * Store the data for a single extension in the cache
280cf2dcf1bSAndreas Gohr     *
281cf2dcf1bSAndreas Gohr     * @param string $id
282cf2dcf1bSAndreas Gohr     * @param array $data
283cf2dcf1bSAndreas Gohr     */
284cf2dcf1bSAndreas Gohr    protected function storeCache($id, $data)
285cf2dcf1bSAndreas Gohr    {
286cf2dcf1bSAndreas Gohr        $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
287cf2dcf1bSAndreas Gohr        $cache->storeCache(serialize($data));
288cf2dcf1bSAndreas Gohr    }
289cf2dcf1bSAndreas Gohr
290cf2dcf1bSAndreas Gohr    /**
291cf2dcf1bSAndreas Gohr     * Retrieve the data for a single extension from the cache
292cf2dcf1bSAndreas Gohr     *
293cf2dcf1bSAndreas Gohr     * @param string $id
294cf2dcf1bSAndreas Gohr     * @return array|null the data or null if not in cache
295cf2dcf1bSAndreas Gohr     */
296cf2dcf1bSAndreas Gohr    protected function retrieveCache($id)
297cf2dcf1bSAndreas Gohr    {
298cf2dcf1bSAndreas Gohr        $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
299cf2dcf1bSAndreas Gohr        if ($cache->useCache(['age' => self::CACHE_TIME])) {
300cf2dcf1bSAndreas Gohr            return unserialize($cache->retrieveCache(false));
301cf2dcf1bSAndreas Gohr        }
302cf2dcf1bSAndreas Gohr        return null;
303cf2dcf1bSAndreas Gohr    }
304cf2dcf1bSAndreas Gohr}
305