diff --git a/CHANGELOG.md b/CHANGELOG.md index b8024657..a2eff6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - ClickHouse: Fix offset (bug #1188) - ClickHouse: Fix list of tables (bug #1176) - Plugins: Methods showVariables() and showStatus() (bug #1157) +- New plugin: IGDB driver ## Adminer 5.4.1 (released 2025-09-26) - SQL command: Unlink NULL primary keys diff --git a/adminer/file.inc.php b/adminer/file.inc.php index e33abe9d..ca2aa27a 100644 --- a/adminer/file.inc.php +++ b/adminer/file.inc.php @@ -34,7 +34,8 @@ if ($_GET["file"] == "default.css") { ../externals/jush/modules/jush-sqlite.js; ../externals/jush/modules/jush-mssql.js; ../externals/jush/modules/jush-oracle.js; -../externals/jush/modules/jush-simpledb.js', 'minify_js')); +../externals/jush/modules/jush-simpledb.js; +../externals/jush/modules/jush-igdb.js', 'minify_js')); } elseif ($_GET["file"] == "logo.png") { header("Content-Type: image/png"); echo compile_file('../adminer/static/logo.png'); diff --git a/externals/jush b/externals/jush index 19fef253..d5b3666f 160000 --- a/externals/jush +++ b/externals/jush @@ -1 +1 @@ -Subproject commit 19fef25342407d2ad696f27ac7f195025c71b89b +Subproject commit d5b3666fc80771fa97487ee959c93c86f1a2ad69 diff --git a/plugins/drivers/igdb.php b/plugins/drivers/igdb.php new file mode 100644 index 00000000..965807cd --- /dev/null +++ b/plugins/drivers/igdb.php @@ -0,0 +1,351 @@ +username = $username; + $this->password = $password; + return ''; + } + + function select_db($database) { + return ($database == "api"); + } + + function request($endpoint, $query) { + $context = stream_context_create(array('http' => array( + 'method' => 'POST', + 'header' => array( + "Content-Type: text/plain", + "Client-ID: $this->username", + "Authorization: Bearer $this->password", + ), + 'content' => $query, + 'ignore_errors' => true, + ))); + $response = file_get_contents("https://api.igdb.com/v4/$endpoint", false, $context); + $return = json_decode($response, true); + if ($http_response_header[0] != 'HTTP/1.1 200 OK') { + if (is_array($return)) { + foreach (is_array($return[0]) ? $return : array($return) as $rows) { + foreach ($rows as $key => $val) { + $this->error .= '' . h($key) . ': ' . (is_url($val) ? '' . h($val) . '' : h($val)) . '
'; + } + } + } else { + $this->error = h($response); + } + return false; + } + return $return; + } + + function query($query, $unbuffered = false) { + if (preg_match('~^SELECT COUNT\(\*\) FROM (\w+)( WHERE ((MATCH \(search\) AGAINST \((.+)\))|.+))?$~', $query, $match)) { + return new Result(array($this->request("$match[1]/count", ($match[5] ? 'search "' . addcslashes($match[5], '\\"') . '";' + : ($match[3] ? 'where ' . str_replace(' AND ', ' & ', $match[3]) . ';' + : '' + ))))); + } + if (preg_match('~^\s*endpoint\s+(\w+(/count)?)\s*;\s*(.*)$~s', $query, $match)) { + $endpoint = $match[1]; + $response = $this->request($endpoint, $match[3]); + if ($response === false) { + return $response; + } + $return = new Result($match[2] ? array($response) : $response); + $return->table = $endpoint; + if ($endpoint == 'multiquery') { + $return->results = $response; + } + return $return; + } + $this->error = "Syntax:
DELIMITER ;;
endpoint <endpoint>; fields ...;"; + return false; + } + + function store_result() { + if ($this->multi && ($result = current($this->multi->results))) { + echo "

" . h($result['name']) . "

\n"; + $this->multi->__construct($result['count'] ? array(array('count' => $result['count'])) : $result['result']); + } + return $this->multi; + } + + function next_result(): bool { + return $this->multi && next($this->multi->results); + } + + function quote($string): string { + return $string; + } + } + + class Result { + public $num_rows; + public $table; + public $results = array(); + private $result; + private $fields; + + function __construct($result) { + $keys = array(); + foreach ($result as $i => $row) { + foreach ($row as $key => $val) { + $keys[$key] = null; + if (is_array($val) && is_int($val[0])) { + $result[$i][$key] = "(" . implode(",", $val) . ")"; + } + } + } + foreach ($result as $i => $row) { + $result[$i] = array_merge($keys, $row); + } + $this->result = $result; + $this->num_rows = count($result); + $this->fields = array_keys(idx($result, 0, array())); + } + + function fetch_assoc() { + $row = current($this->result); + next($this->result); + return $row; + } + + function fetch_row() { + $row = $this->fetch_assoc(); + return ($row ? array_values($row) : false); + } + + function fetch_field(): \stdClass { + $field = current($this->fields); + next($this->fields); + return ($field != '' ? (object) array('name' => $field, 'type' => 15, 'charsetnr' => 0, 'orgtable' => $this->table) : false); + } + } + + class Driver extends SqlDriver { + static $extensions = array("json"); + static $jush = "igdb"; + private static $docsFilename = __DIR__ . DIRECTORY_SEPARATOR . 'igdb-api.html'; + + public $operators = array("=", "<", ">", "<=", ">=", "!=", "~"); + + public $tables = array(); + public $links = array(); + public $fields = array(); + public $foreignKeys = array(); + public $foundRows = null; + + static function connect(string $server, string $username, string $password) { + if (!file_exists(self::$docsFilename)) { + return "Download https://api-docs.igdb.com/ and save it as " . self::$docsFilename; // copy() doesn't work - bot protection + } + return parent::connect($server, $username, $password); + } + + function __construct($connection) { + parent::__construct($connection); + libxml_use_internal_errors(true); + $dom = new \DOMDocument(); + $dom->loadHTMLFile(self::$docsFilename); + $xpath = new \DOMXPath($dom); + $els = $xpath->query('//div[@class="content"]/*'); + $link = ''; + foreach ($els as $i => $el) { + if ($el->tagName == 'h2') { + $link = $el->getAttribute('id'); + } + if ($el->nodeValue == 'Request Path') { + $table = preg_replace('~^https://api.igdb.com/v4/~', '', $els[$i+1]->firstElementChild->nodeValue); + $this->fields[$table]['id'] = array('full_type' => 'bigserial', 'comment' => ''); + $this->links[$link] = $table; + $this->tables[$table] = array( + 'Name' => $table, + 'Comment' => $els[$i-1]->tagName == 'p' ? $els[$i-1]->nodeValue : '', + ); + foreach ($xpath->query('tbody/tr', $els[$i+2]) as $tr) { + $tds = $xpath->query('td', $tr); + $comment = $tds[2]->nodeValue; + if (!preg_match('~^DEPRECATED!~', $comment)) { + $field = $tds[0]->nodeValue; + $this->fields[$table][$field] = array( + 'full_type' => str_replace(' ', ' ', $tds[1]->nodeValue), + 'comment' => str_replace(' ', ' ', $comment), + ); + $ref = $xpath->query('a/@href', $tds[1]); + if (count($ref) && !in_array($ref[0]->value, array('#game-version-feature-enums', '#tag-numbers'))) { + $this->foreignKeys[$table][$field] = substr($ref[0]->value, 1); + } + } + } + } + } + } + + function select($table, $select, $where, $group, $order = array(), $limit = 1, $page = 0, $print = false) { + $query = ''; + $search = preg_match('~^MATCH \(search\) AGAINST \((.+)\)$~', $where[0], $match); + if ($search) { + $query = 'search "' . addcslashes($match[1], '\\"') . "\";\n"; + unset($where[0]); + } + foreach ($where as $i => $val) { + $where[$i] = str_replace(' OR ', ' | ', $val); + } + $common = ($where ? "where " . implode(" & ", $where) . ";" : ""); + $query .= "fields " . implode(",", $select) . ";" + . ($select == array('*') ? "\nexclude checksum;" : "") + . ($where ? "\n$common" : "") + . ($order ? "\nsort " . strtolower(implode(",", $order)) . ";" : "") + . "\nlimit $limit;" + . ($page ? "\noffset " . ($page * $limit) . ";" : "") + ; + $start = microtime(true); + $return = ($search || !array_key_exists($table, driver()->tables) + ? $this->conn->request($table, $query) + : $this->conn->request('multiquery', "query $table \"result\" { $query };\nquery $table/count \"count\" { $common };") + ); + if ($print) { + echo adminer()->selectQuery("DELIMITER ;;\nendpoint $table;\n$query", $start); + } + if ($return === false) { + return $return; + } + $this->foundRows = ($search ? null : $return[1]['count']); + $return = ($search ? $return : $return[0]['result']); + if ($return) { + $keys = ($select != array('*') ? $select : array_diff(array_keys($this->fields[$table]), array('checksum'))); + $return[0] = array_merge(array_fill_keys($keys, null), $return[0]); + } + return new Result($return); + } + + function value($val, $field): ?string { + return ($val && in_array($field['full_type'], array('Unix Time Stamp', 'datetime')) ? str_replace(' 00:00:00', '', gmdate('Y-m-d H:i:s', $val)) : $val); + } + + function tableHelp($name, $is_view = false) { + return strtolower("https://api-docs.igdb.com/#" . array_search($name, $this->links)); + } + } + + function logged_user() { + return $_GET["username"]; + } + + function get_databases($flush) { + return array("api"); + } + + function collations() { + return array(); + } + + function db_collation($db, $collations) { + } + + function information_schema($db) { + } + + function indexes($table, $connection2 = null) { + $return = array(array("type" => "PRIMARY", "columns" => array("id"))); + if (in_array($table, array('characters', 'collections', 'games', 'platforms', 'themes'))) { // https://api-docs.igdb.com/#search-1 + $return[] = array("type" => "FULLTEXT", "columns" => array("search")); + } + return $return; + } + + function fields($table) { + $return = array(); + foreach (driver()->fields[$table] ?: array() as $key => $val) { + $return[$key] = $val + array( + "field" => $key, + "type" => (preg_match('~^int|bool~i', $val['full_type']) ? $val['full_type'] : 'varchar'), // shorten reference columns + "privileges" => array("select" => 1, "update" => 1, "where" => 1, "order" => 1), + ); + } + return $return; + } + + function convert_field($field) { + } + + function unconvert_field($field, $return) { + return $return; + } + + function limit($query, $where, $limit, $offset = 0, $separator = " ") { + return $query; + } + + function idf_escape($idf) { + return $idf; + } + + function table($idf) { + return idf_escape($idf); + } + + function foreign_keys($table) { + $return = array(); + foreach (driver()->foreignKeys[$table] ?: array() as $key => $val) { + $return[] = array( + 'table' => driver()->links[$val], + 'source' => array($key), + 'target' => array('id'), + ); + } + return $return; + } + + function tables_list() { + return array_fill_keys(array_keys(table_status()), 'table'); + } + + function table_status($name = "", $fast = false) { + $tables = driver()->tables; + return ($name != '' ? ($tables[$name] ? array($name => $tables[$name]) : array()) : $tables); + } + + function count_tables($databases) { + return array(reset($databases) => count(tables_list())); + } + + function error() { + return connection()->error; + } + + function is_view($table_status) { + return false; + } + + function found_rows($table_status, $where) { + return driver()->foundRows; + } + + function fk_support($table_status) { + return true; + } + + function support($feature) { + return in_array($feature, array('columns', 'comment', 'sql', 'table')); + } +}