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