Update wiki links to the new short URL
[aur.git] / web / lib / aurjson.class.php
blob86eae22ba7bb392c5536bb29f33516e227559ec1
1 <?php
3 include_once("aur.inc.php");
4 include_once("pkgfuncs.inc.php");
6 /*
7 * This class defines a remote interface for fetching data from the AUR using
8 * JSON formatted elements.
10 * @package rpc
11 * @subpackage classes
13 class AurJSON {
14 private $dbh = false;
15 private $version = 1;
16 private static $exposed_methods = array(
17 'search', 'info', 'multiinfo', 'msearch', 'suggest',
18 'suggest-pkgbase', 'get-comment-form'
20 private static $exposed_fields = array(
21 'name', 'name-desc', 'maintainer',
22 'depends', 'makedepends', 'checkdepends', 'optdepends'
24 private static $exposed_depfields = array(
25 'depends', 'makedepends', 'checkdepends', 'optdepends'
27 private static $fields_v1 = array(
28 'Packages.ID', 'Packages.Name',
29 'PackageBases.ID AS PackageBaseID',
30 'PackageBases.Name AS PackageBase', 'Version',
31 'Description', 'URL', 'NumVotes', 'OutOfDateTS AS OutOfDate',
32 'Users.UserName AS Maintainer',
33 'SubmittedTS AS FirstSubmitted', 'ModifiedTS AS LastModified',
34 'Licenses.Name AS License'
36 private static $fields_v2 = array(
37 'Packages.ID', 'Packages.Name',
38 'PackageBases.ID AS PackageBaseID',
39 'PackageBases.Name AS PackageBase', 'Version',
40 'Description', 'URL', 'NumVotes', 'OutOfDateTS AS OutOfDate',
41 'Users.UserName AS Maintainer',
42 'SubmittedTS AS FirstSubmitted', 'ModifiedTS AS LastModified'
44 private static $fields_v4 = array(
45 'Packages.ID', 'Packages.Name',
46 'PackageBases.ID AS PackageBaseID',
47 'PackageBases.Name AS PackageBase', 'Version',
48 'Description', 'URL', 'NumVotes', 'Popularity',
49 'OutOfDateTS AS OutOfDate', 'Users.UserName AS Maintainer',
50 'SubmittedTS AS FirstSubmitted', 'ModifiedTS AS LastModified'
52 private static $numeric_fields = array(
53 'ID', 'PackageBaseID', 'NumVotes', 'OutOfDate',
54 'FirstSubmitted', 'LastModified'
56 private static $decimal_fields = array(
57 'Popularity'
61 * Handles post data, and routes the request.
63 * @param string $post_data The post data to parse and handle.
65 * @return string The JSON formatted response data.
67 public function handle($http_data) {
69 * Unset global aur.inc.php Pragma header. We want to allow
70 * caching of data in proxies, but require validation of data
71 * (if-none-match) if possible.
73 header_remove('Pragma');
75 * Overwrite cache-control header set in aur.inc.php to allow
76 * caching, but require validation.
78 header('Cache-Control: public, must-revalidate, max-age=0');
79 header('Content-Type: application/json, charset=utf-8');
81 if (isset($http_data['v'])) {
82 $this->version = intval($http_data['v']);
84 if ($this->version < 1 || $this->version > 6) {
85 return $this->json_error('Invalid version specified.');
88 if (!isset($http_data['type']) || !isset($http_data['arg'])) {
89 return $this->json_error('No request type/data specified.');
91 if (!in_array($http_data['type'], self::$exposed_methods)) {
92 return $this->json_error('Incorrect request type specified.');
95 if (isset($http_data['search_by']) && !isset($http_data['by'])) {
96 $http_data['by'] = $http_data['search_by'];
98 if (isset($http_data['by']) && !in_array($http_data['by'], self::$exposed_fields)) {
99 return $this->json_error('Incorrect by field specified.');
102 $this->dbh = DB::connect();
104 if ($this->check_ratelimit($_SERVER['REMOTE_ADDR'])) {
105 header("HTTP/1.1 429 Too Many Requests");
106 return $this->json_error('Rate limit reached');
109 $type = str_replace('-', '_', $http_data['type']);
110 if ($type == 'info' && $this->version >= 5) {
111 $type = 'multiinfo';
113 $json = call_user_func(array(&$this, $type), $http_data);
115 $etag = md5($json);
116 header("Etag: \"$etag\"");
118 * Make sure to strip a few things off the
119 * if-none-match header. Stripping whitespace may not
120 * be required, but removing the quote on the incoming
121 * header is required to make the equality test.
123 $if_none_match = isset($_SERVER['HTTP_IF_NONE_MATCH']) ?
124 trim($_SERVER['HTTP_IF_NONE_MATCH'], "\t\n\r\" ") : false;
125 if ($if_none_match && $if_none_match == $etag) {
126 header('HTTP/1.1 304 Not Modified');
127 return;
130 if (isset($http_data['callback'])) {
131 $callback = $http_data['callback'];
132 if (!preg_match('/^[a-zA-Z0-9()_.]{1,128}$/D', $callback)) {
133 return $this->json_error('Invalid callback name.');
135 header('content-type: text/javascript');
136 return '/**/' . $callback . '(' . $json . ')';
137 } else {
138 header('content-type: application/json');
139 return $json;
144 * Check if an IP needs to be rate limited.
146 * @param $ip IP of the current request
148 * @return true if IP needs to be rate limited, false otherwise.
150 private function check_ratelimit($ip) {
151 $limit = config_get("ratelimit", "request_limit");
152 if ($limit == 0) {
153 return false;
156 $this->update_ratelimit($ip);
158 $status = false;
159 $value = get_cache_value('ratelimit:' . $ip, $status);
160 if (!$status) {
161 $stmt = $this->dbh->prepare("
162 SELECT Requests FROM ApiRateLimit
163 WHERE IP = :ip");
164 $stmt->bindParam(":ip", $ip);
165 $result = $stmt->execute();
167 if (!$result) {
168 return false;
171 $row = $stmt->fetch(PDO::FETCH_ASSOC);
172 $value = $row['Requests'];
175 return $value > $limit;
179 * Update a rate limit for an IP by increasing it's requests value by one.
181 * @param $ip IP of the current request
183 * @return void
185 private function update_ratelimit($ip) {
186 $window_length = config_get("ratelimit", "window_length");
187 $db_backend = config_get("database", "backend");
188 $time = time();
189 $deletion_time = $time - $window_length;
191 /* Try to use the cache. */
192 $status = false;
193 $value = get_cache_value('ratelimit-ws:' . $ip, $status);
194 if (!$status || ($status && $value < $deletion_time)) {
195 if (set_cache_value('ratelimit-ws:' . $ip, $time, $window_length) &&
196 set_cache_value('ratelimit:' . $ip, 1, $window_length)) {
197 return;
199 } else {
200 $value = get_cache_value('ratelimit:' . $ip, $status);
201 if ($status && set_cache_value('ratelimit:' . $ip, $value + 1, $window_length))
202 return;
205 /* Clean up old windows. */
206 $stmt = $this->dbh->prepare("
207 DELETE FROM ApiRateLimit
208 WHERE WindowStart < :time");
209 $stmt->bindParam(":time", $deletion_time);
210 $stmt->execute();
212 if ($db_backend == "mysql") {
213 $stmt = $this->dbh->prepare("
214 INSERT INTO ApiRateLimit
215 (IP, Requests, WindowStart)
216 VALUES (:ip, 1, :window_start)
217 ON DUPLICATE KEY UPDATE Requests=Requests+1");
218 $stmt->bindParam(":ip", $ip);
219 $stmt->bindParam(":window_start", $time);
220 $stmt->execute();
221 } elseif ($db_backend == "sqlite") {
222 $stmt = $this->dbh->prepare("
223 INSERT OR IGNORE INTO ApiRateLimit
224 (IP, Requests, WindowStart)
225 VALUES (:ip, 0, :window_start);");
226 $stmt->bindParam(":ip", $ip);
227 $stmt->bindParam(":window_start", $time);
228 $stmt->execute();
230 $stmt = $this->dbh->prepare("
231 UPDATE ApiRateLimit
232 SET Requests = Requests + 1
233 WHERE IP = :ip");
234 $stmt->bindParam(":ip", $ip);
235 $stmt->execute();
236 } else {
237 throw new RuntimeException("Unknown database backend");
242 * Returns a JSON formatted error string.
244 * @param $msg The error string to return
246 * @return mixed A json formatted error response.
248 private function json_error($msg) {
249 header('content-type: application/json');
250 if ($this->version < 3) {
251 return $this->json_results('error', 0, $msg, NULL);
252 } elseif ($this->version >= 3) {
253 return $this->json_results('error', 0, array(), $msg);
258 * Returns a JSON formatted result data.
260 * @param $type The response method type.
261 * @param $count The number of results to return
262 * @param $data The result data to return
263 * @param $error An error message to include in the response
265 * @return mixed A json formatted result response.
267 private function json_results($type, $count, $data, $error) {
268 $json_array = array(
269 'version' => $this->version,
270 'type' => $type,
271 'resultcount' => $count,
272 'results' => $data
275 if ($error) {
276 $json_array['error'] = $error;
279 return json_encode($json_array);
283 * Get extended package details (for info and multiinfo queries).
285 * @param $pkgid The ID of the package to retrieve details for.
286 * @param $base_id The ID of the package base to retrieve details for.
288 * @return array An array containing package details.
290 private function get_extended_fields($pkgid, $base_id) {
291 $query = "SELECT DependencyTypes.Name AS Type, " .
292 "PackageDepends.DepName AS Name, " .
293 "PackageDepends.DepCondition AS Cond " .
294 "FROM PackageDepends " .
295 "LEFT JOIN DependencyTypes " .
296 "ON DependencyTypes.ID = PackageDepends.DepTypeID " .
297 "WHERE PackageDepends.PackageID = " . $pkgid . " " .
298 "UNION SELECT RelationTypes.Name AS Type, " .
299 "PackageRelations.RelName AS Name, " .
300 "PackageRelations.RelCondition AS Cond " .
301 "FROM PackageRelations " .
302 "LEFT JOIN RelationTypes " .
303 "ON RelationTypes.ID = PackageRelations.RelTypeID " .
304 "WHERE PackageRelations.PackageID = " . $pkgid . " " .
305 "UNION SELECT 'groups' AS Type, `Groups`.`Name`, '' AS Cond " .
306 "FROM `Groups` INNER JOIN PackageGroups " .
307 "ON PackageGroups.PackageID = " . $pkgid . " " .
308 "AND PackageGroups.GroupID = `Groups`.ID " .
309 "UNION SELECT 'license' AS Type, Licenses.Name, '' AS Cond " .
310 "FROM Licenses INNER JOIN PackageLicenses " .
311 "ON PackageLicenses.PackageID = " . $pkgid . " " .
312 "AND PackageLicenses.LicenseID = Licenses.ID";
313 $ttl = config_get_int('options', 'cache_pkginfo_ttl');
314 $rows = db_cache_result($query, 'extended-fields:' . $pkgid, PDO::FETCH_ASSOC, $ttl);
316 $type_map = array(
317 'depends' => 'Depends',
318 'makedepends' => 'MakeDepends',
319 'checkdepends' => 'CheckDepends',
320 'optdepends' => 'OptDepends',
321 'conflicts' => 'Conflicts',
322 'provides' => 'Provides',
323 'replaces' => 'Replaces',
324 'groups' => 'Groups',
325 'license' => 'License',
327 $data = array();
328 foreach ($rows as $row) {
329 $type = $type_map[$row['Type']];
330 $data[$type][] = $row['Name'] . $row['Cond'];
333 if ($this->version >= 5) {
334 $query = "SELECT Keyword FROM PackageKeywords " .
335 "WHERE PackageBaseID = " . intval($base_id) . " " .
336 "ORDER BY Keyword ASC";
337 $ttl = config_get_int('options', 'cache_pkginfo_ttl');
338 $rows = db_cache_result($query, 'keywords:' . intval($base_id), PDO::FETCH_NUM, $ttl);
339 $data['Keywords'] = array_map(function ($x) { return $x[0]; }, $rows);
342 return $data;
346 * Retrieve package information (used in info, multiinfo, search and
347 * depends requests).
349 * @param $type The request type.
350 * @param $where_condition An SQL WHERE-condition to filter packages.
352 * @return mixed Returns an array of package matches.
354 private function process_query($type, $where_condition) {
355 $max_results = config_get_int('options', 'max_rpc_results');
357 if ($this->version == 1) {
358 $fields = implode(',', self::$fields_v1);
359 $query = "SELECT {$fields} " .
360 "FROM Packages LEFT JOIN PackageBases " .
361 "ON PackageBases.ID = Packages.PackageBaseID " .
362 "LEFT JOIN Users " .
363 "ON PackageBases.MaintainerUID = Users.ID " .
364 "LEFT JOIN PackageLicenses " .
365 "ON PackageLicenses.PackageID = Packages.ID " .
366 "LEFT JOIN Licenses " .
367 "ON Licenses.ID = PackageLicenses.LicenseID " .
368 "WHERE ${where_condition} " .
369 "AND PackageBases.PackagerUID IS NOT NULL " .
370 "LIMIT $max_results";
371 } elseif ($this->version >= 2) {
372 if ($this->version == 2 || $this->version == 3) {
373 $fields = implode(',', self::$fields_v2);
374 } else if ($this->version >= 4 && $this->version <= 6) {
375 $fields = implode(',', self::$fields_v4);
377 $query = "SELECT {$fields} " .
378 "FROM Packages LEFT JOIN PackageBases " .
379 "ON PackageBases.ID = Packages.PackageBaseID " .
380 "LEFT JOIN Users " .
381 "ON PackageBases.MaintainerUID = Users.ID " .
382 "WHERE ${where_condition} " .
383 "AND PackageBases.PackagerUID IS NOT NULL " .
384 "LIMIT $max_results";
386 $result = $this->dbh->query($query);
388 if ($result) {
389 $resultcount = 0;
390 $search_data = array();
391 while ($row = $result->fetch(PDO::FETCH_ASSOC)) {
392 $resultcount++;
393 $row['URLPath'] = sprintf(config_get('options', 'snapshot_uri'), urlencode($row['PackageBase']));
394 if ($this->version < 4) {
395 $row['CategoryID'] = 1;
399 * Unfortunately, mysql_fetch_assoc() returns
400 * all fields as strings. We need to coerce
401 * numeric values into integers to provide
402 * proper data types in the JSON response.
404 foreach (self::$numeric_fields as $field) {
405 if (isset($row[$field])) {
406 $row[$field] = intval($row[$field]);
410 foreach (self::$decimal_fields as $field) {
411 if (isset($row[$field])) {
412 $row[$field] = floatval($row[$field]);
416 if ($this->version >= 2 && ($type == 'info' || $type == 'multiinfo')) {
417 $extfields = $this->get_extended_fields($row['ID'], $row['PackageBaseID']);
418 if ($extfields) {
419 $row = array_merge($row, $extfields);
423 if ($this->version < 3) {
424 if ($type == 'info') {
425 $search_data = $row;
426 break;
427 } else {
428 array_push($search_data, $row);
430 } elseif ($this->version >= 3) {
431 array_push($search_data, $row);
435 if ($resultcount === $max_results) {
436 return $this->json_error('Too many package results.');
439 return $this->json_results($type, $resultcount, $search_data, NULL);
440 } else {
441 return $this->json_results($type, 0, array(), NULL);
446 * Parse the args to the multiinfo function. We may have a string or an
447 * array, so do the appropriate thing. Within the elements, both * package
448 * IDs and package names are valid; sort them into the relevant arrays and
449 * escape/quote the names.
451 * @param array $args Query parameters.
453 * @return mixed An array containing 'ids' and 'names'.
455 private function parse_multiinfo_args($args) {
456 if (!is_array($args)) {
457 $args = array($args);
460 $id_args = array();
461 $name_args = array();
462 foreach ($args as $arg) {
463 if (!$arg) {
464 continue;
466 if ($this->version < 5 && is_numeric($arg)) {
467 $id_args[] = intval($arg);
468 } else {
469 $name_args[] = $this->dbh->quote($arg);
473 return array('ids' => $id_args, 'names' => $name_args);
477 * Performs a fulltext mysql search of the package database.
479 * @param array $http_data Query parameters.
481 * @return mixed Returns an array of package matches.
483 private function search($http_data) {
484 $keyword_string = $http_data['arg'];
486 if (isset($http_data['by'])) {
487 $search_by = $http_data['by'];
488 } else {
489 $search_by = 'name-desc';
492 if ($search_by === 'name' || $search_by === 'name-desc') {
493 if (strlen($keyword_string) < 2) {
494 return $this->json_error('Query arg too small.');
497 if ($this->version >= 6 && $search_by === 'name-desc') {
498 $where_condition = construct_keyword_search($this->dbh,
499 $keyword_string, true, false);
500 } else {
501 $keyword_string = $this->dbh->quote(
502 "%" . addcslashes($keyword_string, '%_') . "%");
504 if ($search_by === 'name') {
505 $where_condition = "(Packages.Name LIKE $keyword_string)";
506 } else if ($search_by === 'name-desc') {
507 $where_condition = "(Packages.Name LIKE $keyword_string ";
508 $where_condition .= "OR Description LIKE $keyword_string)";
512 } else if ($search_by === 'maintainer') {
513 if (empty($keyword_string)) {
514 $where_condition = "Users.ID is NULL";
515 } else {
516 $keyword_string = $this->dbh->quote($keyword_string);
517 $where_condition = "Users.Username = $keyword_string ";
519 } else if (in_array($search_by, self::$exposed_depfields)) {
520 if (empty($keyword_string)) {
521 return $this->json_error('Query arg is empty.');
522 } else {
523 $keyword_string = $this->dbh->quote($keyword_string);
524 $search_by = $this->dbh->quote($search_by);
525 $subquery = "SELECT PackageDepends.DepName FROM PackageDepends ";
526 $subquery .= "LEFT JOIN DependencyTypes ";
527 $subquery .= "ON PackageDepends.DepTypeID = DependencyTypes.ID ";
528 $subquery .= "WHERE PackageDepends.PackageID = Packages.ID ";
529 $subquery .= "AND DependencyTypes.Name = $search_by";
530 $where_condition = "$keyword_string IN ($subquery)";
534 return $this->process_query('search', $where_condition);
538 * Returns the info on a specific package.
540 * @param array $http_data Query parameters.
542 * @return mixed Returns an array of value data containing the package data
544 private function info($http_data) {
545 $pqdata = $http_data['arg'];
546 if ($this->version < 5 && is_numeric($pqdata)) {
547 $where_condition = "Packages.ID = $pqdata";
548 } else {
549 $where_condition = "Packages.Name = " . $this->dbh->quote($pqdata);
552 return $this->process_query('info', $where_condition);
556 * Returns the info on multiple packages.
558 * @param array $http_data Query parameters.
560 * @return mixed Returns an array of results containing the package data
562 private function multiinfo($http_data) {
563 $pqdata = $http_data['arg'];
564 $args = $this->parse_multiinfo_args($pqdata);
565 $ids = $args['ids'];
566 $names = $args['names'];
568 if (!$ids && !$names) {
569 return $this->json_error('Invalid query arguments.');
572 $where_condition = "";
573 if ($ids) {
574 $ids_value = implode(',', $args['ids']);
575 $where_condition .= "Packages.ID IN ($ids_value) ";
577 if ($ids && $names) {
578 $where_condition .= "OR ";
580 if ($names) {
582 * Individual names were quoted in
583 * parse_multiinfo_args().
585 $names_value = implode(',', $args['names']);
586 $where_condition .= "Packages.Name IN ($names_value) ";
589 return $this->process_query('multiinfo', $where_condition);
593 * Returns all the packages for a specific maintainer.
595 * @param array $http_data Query parameters.
597 * @return mixed Returns an array of value data containing the package data
599 private function msearch($http_data) {
600 $http_data['by'] = 'maintainer';
601 return $this->search($http_data);
605 * Get all package names that start with $search.
607 * @param array $http_data Query parameters.
609 * @return string The JSON formatted response data.
611 private function suggest($http_data) {
612 $search = $http_data['arg'];
613 $query = "SELECT Packages.Name FROM Packages ";
614 $query.= "LEFT JOIN PackageBases ";
615 $query.= "ON PackageBases.ID = Packages.PackageBaseID ";
616 $query.= "WHERE Packages.Name LIKE ";
617 $query.= $this->dbh->quote(addcslashes($search, '%_') . '%');
618 $query.= " AND PackageBases.PackagerUID IS NOT NULL ";
619 $query.= "ORDER BY Name ASC LIMIT 20";
621 $result = $this->dbh->query($query);
622 $result_array = array();
624 if ($result) {
625 $result_array = $result->fetchAll(PDO::FETCH_COLUMN, 0);
628 return json_encode($result_array);
632 * Get all package base names that start with $search.
634 * @param array $http_data Query parameters.
636 * @return string The JSON formatted response data.
638 private function suggest_pkgbase($http_data) {
639 $search = $http_data['arg'];
640 $query = "SELECT Name FROM PackageBases WHERE Name LIKE ";
641 $query.= $this->dbh->quote(addcslashes($search, '%_') . '%');
642 $query.= " AND PackageBases.PackagerUID IS NOT NULL ";
643 $query.= "ORDER BY Name ASC LIMIT 20";
645 $result = $this->dbh->query($query);
646 $result_array = array();
648 if ($result) {
649 $result_array = $result->fetchAll(PDO::FETCH_COLUMN, 0);
652 return json_encode($result_array);
656 * Get the HTML markup of the comment form.
658 * @param array $http_data Query parameters.
660 * @return string The JSON formatted response data.
662 private function get_comment_form($http_data) {
663 if (!isset($http_data['base_id']) || !isset($http_data['pkgbase_name'])) {
664 $output = array(
665 'success' => 0,
666 'error' => __('Package base ID or package base name missing.')
668 return json_encode($output);
671 $comment_id = intval($http_data['arg']);
672 $base_id = intval($http_data['base_id']);
673 $pkgbase_name = $http_data['pkgbase_name'];
675 list($user_id, $comment) = comment_by_id($comment_id);
677 if (!has_credential(CRED_COMMENT_EDIT, array($user_id))) {
678 $output = array(
679 'success' => 0,
680 'error' => __('You are not allowed to edit this comment.')
682 return json_encode($output);
683 } elseif (is_null($comment)) {
684 $output = array(
685 'success' => 0,
686 'error' => __('Comment does not exist.')
688 return json_encode($output);
691 ob_start();
692 include('pkg_comment_form.php');
693 $html = ob_get_clean();
694 $output = array(
695 'success' => 1,
696 'form' => $html
699 return json_encode($output);