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