xref: /dokuwiki/lib/plugins/extension/Repository.php (revision 01b2a2823dcc82af6193cb9ea39d835e2a85f9e2)
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;
65cf2dcf1bSAndreas Gohr        $data = $httpclient->get(self::EXTENSION_REPOSITORY_API . '?cmd=ping');
66cf2dcf1bSAndreas Gohr        if ($data === false) {
67cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
68cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
69cf2dcf1bSAndreas Gohr        } elseif ($data !== '1') {
70cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
71cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse');
72cf2dcf1bSAndreas Gohr        } else {
73cf2dcf1bSAndreas Gohr            $this->hasAccess = true;
74cf2dcf1bSAndreas Gohr        }
75cf2dcf1bSAndreas Gohr        return $this->hasAccess;
76cf2dcf1bSAndreas Gohr    }
77cf2dcf1bSAndreas Gohr
78cf2dcf1bSAndreas Gohr    /**
79cf2dcf1bSAndreas Gohr     * Fetch the data for multiple extensions from the repository
80cf2dcf1bSAndreas Gohr     *
81cf2dcf1bSAndreas Gohr     * @param string[] $ids A list of extension ids
82cf2dcf1bSAndreas Gohr     * @throws Exception
83cf2dcf1bSAndreas Gohr     */
84cf2dcf1bSAndreas Gohr    protected function fetchExtensions($ids)
85cf2dcf1bSAndreas Gohr    {
86cf2dcf1bSAndreas Gohr        if (!$this->checkAccess()) return;
87cf2dcf1bSAndreas Gohr
88cf2dcf1bSAndreas Gohr        $httpclient = new DokuHTTPClient();
89cf2dcf1bSAndreas Gohr        $data = [
90cf2dcf1bSAndreas Gohr            'fmt' => 'json',
91cf2dcf1bSAndreas Gohr            'ext' => $ids
92cf2dcf1bSAndreas Gohr        ];
93cf2dcf1bSAndreas Gohr
94cf2dcf1bSAndreas Gohr        $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $data);
95cf2dcf1bSAndreas Gohr        if ($response === false) {
96cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
97cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
98cf2dcf1bSAndreas Gohr        }
99cf2dcf1bSAndreas Gohr
100cf2dcf1bSAndreas Gohr        try {
101981e70caSAndreas Gohr            $found = [];
102cf2dcf1bSAndreas Gohr            $extensions = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
103cf2dcf1bSAndreas Gohr            foreach ($extensions as $extension) {
104cf2dcf1bSAndreas Gohr                $this->storeCache($extension['plugin'], $extension);
105981e70caSAndreas Gohr                $found[] = $extension['plugin'];
106cf2dcf1bSAndreas Gohr            }
107981e70caSAndreas Gohr            // extensions that have not been returned are not in the repository, but we should cache that too
108981e70caSAndreas Gohr            foreach (array_diff($ids, $found) as $id) {
109981e70caSAndreas Gohr                $this->storeCache($id, []);
110981e70caSAndreas Gohr            }
111981e70caSAndreas Gohr        } catch (JsonException $e) {
112cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
113cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse', 0, $e);
114cf2dcf1bSAndreas Gohr        }
115cf2dcf1bSAndreas Gohr    }
116cf2dcf1bSAndreas Gohr
117cf2dcf1bSAndreas Gohr    /**
118cf2dcf1bSAndreas Gohr     * This creates a list of Extension objects from the given list of ids
119cf2dcf1bSAndreas Gohr     *
120cf2dcf1bSAndreas Gohr     * The extensions are initialized by fetching their data from the cache or the repository.
121cf2dcf1bSAndreas Gohr     * This is the recommended way to initialize a whole bunch of extensions at once as it will only do
122cf2dcf1bSAndreas Gohr     * a single API request for all extensions that are not in the cache.
123cf2dcf1bSAndreas Gohr     *
124cf2dcf1bSAndreas Gohr     * Extensions that are not found in the cache or the repository will be initialized as null.
125cf2dcf1bSAndreas Gohr     *
126cf2dcf1bSAndreas Gohr     * @param string[] $ids
127cf2dcf1bSAndreas Gohr     * @return (Extension|null)[] [id => Extension|null, ...]
128cf2dcf1bSAndreas Gohr     * @throws Exception
129cf2dcf1bSAndreas Gohr     */
130cf2dcf1bSAndreas Gohr    public function initExtensions($ids)
131cf2dcf1bSAndreas Gohr    {
132cf2dcf1bSAndreas Gohr        $result = [];
133cf2dcf1bSAndreas Gohr        $toload = [];
134cf2dcf1bSAndreas Gohr
135cf2dcf1bSAndreas Gohr        // first get all that are cached
136cf2dcf1bSAndreas Gohr        foreach ($ids as $id) {
137cf2dcf1bSAndreas Gohr            $data = $this->retrieveCache($id);
138*01b2a282SAndreas Gohr            if ($data === null || $data === []) {
139cf2dcf1bSAndreas Gohr                $toload[] = $id;
140cf2dcf1bSAndreas Gohr            } else {
141cf2dcf1bSAndreas Gohr                $result[$id] = Extension::createFromRemoteData($data);
142cf2dcf1bSAndreas Gohr            }
143cf2dcf1bSAndreas Gohr        }
144cf2dcf1bSAndreas Gohr
145cf2dcf1bSAndreas Gohr        // then fetch the rest at once
146cf2dcf1bSAndreas Gohr        if ($toload) {
147cf2dcf1bSAndreas Gohr            $this->fetchExtensions($toload);
148cf2dcf1bSAndreas Gohr            foreach ($toload as $id) {
149cf2dcf1bSAndreas Gohr                $data = $this->retrieveCache($id);
150*01b2a282SAndreas Gohr                if ($data === null || $data === []) {
151cf2dcf1bSAndreas Gohr                    $result[$id] = null;
152cf2dcf1bSAndreas Gohr                } else {
153cf2dcf1bSAndreas Gohr                    $result[$id] = Extension::createFromRemoteData($data);
154cf2dcf1bSAndreas Gohr                }
155cf2dcf1bSAndreas Gohr            }
156cf2dcf1bSAndreas Gohr        }
157cf2dcf1bSAndreas Gohr
158cf2dcf1bSAndreas Gohr        return $result;
159cf2dcf1bSAndreas Gohr    }
160cf2dcf1bSAndreas Gohr
161cf2dcf1bSAndreas Gohr    /**
162cf2dcf1bSAndreas Gohr     * Initialize a new Extension object from remote data for the given id
163cf2dcf1bSAndreas Gohr     *
164cf2dcf1bSAndreas Gohr     * @param string $id
165cf2dcf1bSAndreas Gohr     * @return Extension|null
166cf2dcf1bSAndreas Gohr     * @throws Exception
167cf2dcf1bSAndreas Gohr     */
168cf2dcf1bSAndreas Gohr    public function initExtension($id)
169cf2dcf1bSAndreas Gohr    {
170cf2dcf1bSAndreas Gohr        $result = $this->initExtensions([$id]);
171cf2dcf1bSAndreas Gohr        return $result[$id];
172cf2dcf1bSAndreas Gohr    }
173cf2dcf1bSAndreas Gohr
174cf2dcf1bSAndreas Gohr    /**
175cf2dcf1bSAndreas Gohr     * Get the pure API data for a single extension
176cf2dcf1bSAndreas Gohr     *
177cf2dcf1bSAndreas Gohr     * Used when lazy loading remote data in Extension
178cf2dcf1bSAndreas Gohr     *
179cf2dcf1bSAndreas Gohr     * @param string $id
180cf2dcf1bSAndreas Gohr     * @return array|null
181cf2dcf1bSAndreas Gohr     * @throws Exception
182cf2dcf1bSAndreas Gohr     */
183cf2dcf1bSAndreas Gohr    public function getExtensionData($id)
184cf2dcf1bSAndreas Gohr    {
185cf2dcf1bSAndreas Gohr        $data = $this->retrieveCache($id);
186cf2dcf1bSAndreas Gohr        if ($data === null) {
187cf2dcf1bSAndreas Gohr            $this->fetchExtensions([$id]);
188cf2dcf1bSAndreas Gohr            $data = $this->retrieveCache($id);
189cf2dcf1bSAndreas Gohr        }
190cf2dcf1bSAndreas Gohr        return $data;
191cf2dcf1bSAndreas Gohr    }
192cf2dcf1bSAndreas Gohr
193cf2dcf1bSAndreas Gohr    /**
194cf2dcf1bSAndreas Gohr     * Search for extensions using the given query string
195cf2dcf1bSAndreas Gohr     *
196cf2dcf1bSAndreas Gohr     * @param string $q the query string
197cf2dcf1bSAndreas Gohr     * @return Extension[] a list of matching extensions
198cf2dcf1bSAndreas Gohr     * @throws Exception
199cf2dcf1bSAndreas Gohr     */
200cf2dcf1bSAndreas Gohr    public function searchExtensions($q)
201cf2dcf1bSAndreas Gohr    {
202cf2dcf1bSAndreas Gohr        if (!$this->checkAccess()) return [];
203cf2dcf1bSAndreas Gohr
204cf2dcf1bSAndreas Gohr        $query = $this->parseQuery($q);
205cf2dcf1bSAndreas Gohr        $query['fmt'] = 'json';
206cf2dcf1bSAndreas Gohr
207cf2dcf1bSAndreas Gohr        $httpclient = new DokuHTTPClient();
208cf2dcf1bSAndreas Gohr        $response = $httpclient->post(self::EXTENSION_REPOSITORY_API, $query);
209cf2dcf1bSAndreas Gohr        if ($response === false) {
210cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
211cf2dcf1bSAndreas Gohr            throw new Exception('repo_error');
212cf2dcf1bSAndreas Gohr        }
213cf2dcf1bSAndreas Gohr
214cf2dcf1bSAndreas Gohr        try {
215cf2dcf1bSAndreas Gohr            $items = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
216cf2dcf1bSAndreas Gohr        } catch (JsonException $e) {
217cf2dcf1bSAndreas Gohr            $this->hasAccess = false;
218cf2dcf1bSAndreas Gohr            throw new Exception('repo_badresponse', 0, $e);
219cf2dcf1bSAndreas Gohr        }
220cf2dcf1bSAndreas Gohr
221cf2dcf1bSAndreas Gohr        $results = [];
222cf2dcf1bSAndreas Gohr        foreach ($items as $item) {
223cf2dcf1bSAndreas Gohr            $this->storeCache($item['plugin'], $item);
224cf2dcf1bSAndreas Gohr            $results[] = Extension::createFromRemoteData($item);
225cf2dcf1bSAndreas Gohr        }
226cf2dcf1bSAndreas Gohr        return $results;
227cf2dcf1bSAndreas Gohr    }
228cf2dcf1bSAndreas Gohr
229cf2dcf1bSAndreas Gohr    /**
230cf2dcf1bSAndreas Gohr     * Parses special queries from the query string
231cf2dcf1bSAndreas Gohr     *
232cf2dcf1bSAndreas Gohr     * @param string $q
233cf2dcf1bSAndreas Gohr     * @return array
234cf2dcf1bSAndreas Gohr     */
235cf2dcf1bSAndreas Gohr    protected function parseQuery($q)
236cf2dcf1bSAndreas Gohr    {
237cf2dcf1bSAndreas Gohr        $parameters = [
238cf2dcf1bSAndreas Gohr            'tag' => [],
239cf2dcf1bSAndreas Gohr            'mail' => [],
2403e63733dSAndreas Gohr            'type' => 0,
241cf2dcf1bSAndreas Gohr            'ext' => []
242cf2dcf1bSAndreas Gohr        ];
243cf2dcf1bSAndreas Gohr
244cf2dcf1bSAndreas Gohr        // extract tags
245cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(tag:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
246cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
247cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
248cf2dcf1bSAndreas Gohr                $parameters['tag'][] = $m[3];
249cf2dcf1bSAndreas Gohr            }
250cf2dcf1bSAndreas Gohr        }
251cf2dcf1bSAndreas Gohr        // extract author ids
252cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(authorid:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
253cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
254cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
255cf2dcf1bSAndreas Gohr                $parameters['mail'][] = $m[3];
256cf2dcf1bSAndreas Gohr            }
257cf2dcf1bSAndreas Gohr        }
258cf2dcf1bSAndreas Gohr        // extract extensions
259cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(ext:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
260cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
261cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
262cf2dcf1bSAndreas Gohr                $parameters['ext'][] = $m[3];
263cf2dcf1bSAndreas Gohr            }
264cf2dcf1bSAndreas Gohr        }
265cf2dcf1bSAndreas Gohr        // extract types
266cf2dcf1bSAndreas Gohr        if (preg_match_all('/(^|\s)(type:([\S]+))/', $q, $matches, PREG_SET_ORDER)) {
2673e63733dSAndreas Gohr            $typevalues = array_flip(Extension::COMPONENT_TYPES);
2683e63733dSAndreas Gohr            $typevalues = array_change_key_case($typevalues, CASE_LOWER);
2693e63733dSAndreas Gohr
270cf2dcf1bSAndreas Gohr            foreach ($matches as $m) {
271cf2dcf1bSAndreas Gohr                $q = str_replace($m[2], '', $q);
2723e63733dSAndreas Gohr                $t = strtolower($m[3]);
2733e63733dSAndreas Gohr                if (isset($typevalues[$t])) {
2743e63733dSAndreas Gohr                    $parameters['type'] += $typevalues[$t];
275cf2dcf1bSAndreas Gohr                }
276cf2dcf1bSAndreas Gohr            }
2773e63733dSAndreas Gohr        }
278cf2dcf1bSAndreas Gohr
279cf2dcf1bSAndreas Gohr        $parameters['q'] = trim($q);
280cf2dcf1bSAndreas Gohr        return $parameters;
281cf2dcf1bSAndreas Gohr    }
282cf2dcf1bSAndreas Gohr
283cf2dcf1bSAndreas Gohr
284cf2dcf1bSAndreas Gohr    /**
285cf2dcf1bSAndreas Gohr     * Store the data for a single extension in the cache
286cf2dcf1bSAndreas Gohr     *
287cf2dcf1bSAndreas Gohr     * @param string $id
288cf2dcf1bSAndreas Gohr     * @param array $data
289cf2dcf1bSAndreas Gohr     */
290cf2dcf1bSAndreas Gohr    protected function storeCache($id, $data)
291cf2dcf1bSAndreas Gohr    {
292cf2dcf1bSAndreas Gohr        $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
293cf2dcf1bSAndreas Gohr        $cache->storeCache(serialize($data));
294cf2dcf1bSAndreas Gohr    }
295cf2dcf1bSAndreas Gohr
296cf2dcf1bSAndreas Gohr    /**
297cf2dcf1bSAndreas Gohr     * Retrieve the data for a single extension from the cache
298cf2dcf1bSAndreas Gohr     *
299cf2dcf1bSAndreas Gohr     * @param string $id
300cf2dcf1bSAndreas Gohr     * @return array|null the data or null if not in cache
301cf2dcf1bSAndreas Gohr     */
302cf2dcf1bSAndreas Gohr    protected function retrieveCache($id)
303cf2dcf1bSAndreas Gohr    {
304cf2dcf1bSAndreas Gohr        $cache = new Cache(self::CACHE_PREFIX . $id, self::CACHE_SUFFIX);
305cf2dcf1bSAndreas Gohr        if ($cache->useCache(['age' => self::CACHE_TIME])) {
306cf2dcf1bSAndreas Gohr            return unserialize($cache->retrieveCache(false));
307cf2dcf1bSAndreas Gohr        }
308cf2dcf1bSAndreas Gohr        return null;
309cf2dcf1bSAndreas Gohr    }
310cf2dcf1bSAndreas Gohr}
311