diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a5fe8e7 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [erikdubbelboer] diff --git a/.gitignore b/.gitignore index daf3eea..4dc5d11 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ config.inc.php *.phar vendor + +# IDEs metadata +/nbproject/ diff --git a/Dockerfile b/Dockerfile index ebca2d9..610dbda 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,15 @@ -FROM composer/composer +FROM composer:2.2 + +RUN apk add --no-cache tini tzdata -ADD . /src/app/ WORKDIR /src/app -RUN \ - composer install && \ - cp includes/config.environment.inc.php includes/config.inc.php +COPY . . -EXPOSE 80 +RUN set -xe; \ + composer install; \ + cp includes/config.environment.inc.php includes/config.inc.php -ENTRYPOINT [ "php", "-S", "0.0.0.0:80" ] +ENV PORT 80 +EXPOSE 80 +ENTRYPOINT [ "sh", "-c", "tini -- php -S 0.0.0.0:$PORT" ] diff --git a/README.markdown b/README.markdown index 115bcfc..0938c08 100644 --- a/README.markdown +++ b/README.markdown @@ -1,5 +1,5 @@ -phpRedisAdmin 1.6.0 -=================== +phpRedisAdmin +============= phpRedisAdmin is a simple web interface to manage [Redis](http://redis.io/) databases. It is released under the @@ -39,6 +39,29 @@ cd phpRedisAdmin git clone https://github.com/nrk/predis.git vendor ``` +Docker Image +============ +A public [phpRedisAdmin Docker image](https://hub.docker.com/r/erikdubbelboer/phpredisadmin/) is available on Docker Hub built from the latest tag. +The file ```includes/config.environment.inc.php``` is used as the configuration file to allow environment variables to be used as configuration values. +Example: +``` +docker run --rm -it -e REDIS_1_HOST=myredis.host -e REDIS_1_NAME=MyRedis -p 80:80 erikdubbelboer/phpredisadmin +``` +Also, a Docker Compose manifest with a stack for testing and development is provided. Just issue ```docker-compose up --build``` to start it and browse to http://localhost. See ```docker-compose.yml``` file for configuration details. + +Environment variables summary +==== + +* ``REDIS_1_HOST`` - define host of the Redis server +* ``REDIS_1_NAME`` - define name of the Redis server +* ``REDIS_1_PORT`` - define port of the Redis server +* ``REDIS_1_SCHEME`` - define scheme of the Redis server (tcp or tls) +* ``REDIS_1_AUTH`` - define password of the Redis server +* ``REDIS_1_AUTH_FILE`` - define file containing the password of the Redis server +* ``REDIS_1_DATABASES`` - You can modify you config to prevent phpRedisAdmin from using CONFIG command +* ``ADMIN_USER`` - define username for user-facing Basic Auth +* ``ADMIN_PASS`` - define password for user-facing Basic Auth + TODO ==== diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1627ff2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| 1.13.x | :white_check_mark: | +| 1.12.x | :white_check_mark: | +| < 1.12 | :x: | + +## Reporting a Vulnerability + +Vulnerabilities can be emailed to erik@dubbelboer.com diff --git a/composer.json b/composer.json index 287c77e..3b3ab81 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "erik-dubbelboer/php-redis-admin", "description": "Simple web interface to manage Redis databases.", - "version": "1.6.0", - "license": "CC-BY-ND", + "version": "1.24.0", + "license": "CC-BY-3.0", "homepage": "https://github.com/ErikDubbelboer/phpRedisAdmin", "authors": [ { @@ -13,8 +13,11 @@ } ], "require": { - "predis/predis": "1.0.3" + "ext-mbstring": "*", + "ext-json": "*", + "predis/predis": "v2.3.0", + "paragonie/random_compat": ">=2" }, "minimum-stability": "stable", - "target-dir": "ErikDubbelboer/phpRedisAdmin" + "target-dir": "ErikDubbelboer/phpRedisAdmin" } diff --git a/composer.lock b/composer.lock index cd0cb49..f724f31 100644 --- a/composer.lock +++ b/composer.lock @@ -1,35 +1,85 @@ { "_readme": [ "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "hash": "e81aef935c2a6d36cf7690dbdb9d129a", - "content-hash": "3212fc4e8463f3bf5ff7db4655eecbf0", + "content-hash": "401ff61cebe5223d003a47192d7c3d6a", "packages": [ + { + "name": "paragonie/random_compat", + "version": "v9.99.100", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", + "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "shasum": "" + }, + "require": { + "php": ">= 7" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*", + "vimeo/psalm": "^1" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com/" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "polyfill", + "pseudorandom", + "random" + ], + "support": { + "email": "info@paragonie.com", + "issues": "https://github.com/paragonie/random_compat/issues", + "source": "https://github.com/paragonie/random_compat" + }, + "time": "2020-10-15T08:29:30+00:00" + }, { "name": "predis/predis", - "version": "v1.0.3", + "version": "v2.3.0", "source": { "type": "git", - "url": "https://github.com/nrk/predis.git", - "reference": "84060b9034d756b4d79641667d7f9efe1aeb8e04" + "url": "https://github.com/predis/predis.git", + "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nrk/predis/zipball/84060b9034d756b4d79641667d7f9efe1aeb8e04", - "reference": "84060b9034d756b4d79641667d7f9efe1aeb8e04", + "url": "https://api.github.com/repos/predis/predis/zipball/bac46bfdb78cd6e9c7926c697012aae740cb9ec9", + "reference": "bac46bfdb78cd6e9c7926c697012aae740cb9ec9", "shasum": "" }, "require": { - "php": ">=5.3.2" + "php": "^7.2 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "~4.0" + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^8.0 || ^9.4" }, "suggest": { - "ext-curl": "Allows access to Webdis when paired with phpiredis", - "ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol" + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" }, "type": "library", "autoload": { @@ -43,27 +93,41 @@ ], "authors": [ { - "name": "Daniele Alessandri", - "email": "suppakilla@gmail.com", - "homepage": "http://clorophilla.net/" + "name": "Till Krüss", + "homepage": "https://till.im/", + "role": "Maintainer" } ], - "description": "Flexible and feature-complete PHP client library for Redis", - "homepage": "http://github.com/nrk/predis", + "description": "A flexible and feature-complete Redis client for PHP.", + "homepage": "http://github.com/predis/predis", "keywords": [ "nosql", "predis", "redis" ], - "time": "2015-07-30 18:34:15" + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v2.3.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2024-11-21T20:00:02+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [] + "platform": { + "ext-mbstring": "*", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" } diff --git a/css/common.css b/css/common.css index b2a8431..9dc6276 100644 --- a/css/common.css +++ b/css/common.css @@ -52,6 +52,6 @@ background: url(http://github.com/images/add.png) left center no-repeat; .data { -white-space: pre; +white-space: pre-wrap; } diff --git a/css/frame.css b/css/frame.css index e54ca9e..7cf935d 100644 --- a/css/frame.css +++ b/css/frame.css @@ -14,7 +14,6 @@ margin-left: -8em; } form .button { -margin-left: -7em; } @@ -53,3 +52,13 @@ float: left; margin: 1em; } + +.exception { +border: 1px solid #ff0000; +background-color: rgba(255, 0, 0, 0.2); +color: #880000; +padding: 10px; +border-radius: 5px; +margin: 20px; +} + diff --git a/css/index.css b/css/index.css index 2c8faf5..4a56aa9 100644 --- a/css/index.css +++ b/css/index.css @@ -3,10 +3,14 @@ position: absolute; top: 0; bottom: 0; left: 0; +display: flex; +flex-direction: column; width: 290px; height: 100%; +} + +#header { padding-left: 10px; -border-right: 1px solid #000; } #sidebar a, #sidebar a:visited { @@ -23,11 +27,9 @@ text-decoration: underline; #keys { -position: absolute; -top: 18.5em; -left: 0; -bottom: 0; +flex-grow: 1; width: 290px; +margin-top: 10px; padding-left: 10px; overflow: auto; } @@ -40,11 +42,17 @@ padding: 0; #keys li { font-weight: normal; +white-space: nowrap; } #keys li.folder { font-weight: bold; margin-top: .05em; +cursor: pointer; +} + +#keys li.empty a { +color: #888; } #keys li.current a { @@ -114,6 +122,7 @@ background-color: #aaa; cursor: col-resize; padding: 0; margin: 0; +border-left: 1px solid #000; } #resize-layover { @@ -132,11 +141,12 @@ top: 0; left: 305px; right: 0; bottom: 0; -padding-left: 2em; } #frame iframe { -width: 100%; +width: calc(100% - 3em); height: 100%; +margin-left: 1.5em; +margin-right: 1.5em; } diff --git a/css/login.css b/css/login.css new file mode 100644 index 0000000..f439934 --- /dev/null +++ b/css/login.css @@ -0,0 +1,116 @@ +/* Styles borrowed from http://getbootstrap.com/examples/signin/ */ +h1.logo { + text-align: center; +} +h2 { + font-size: 30px; +} +h2 { + margin-top: 20px; + margin-bottom: 10px; +} +h2 { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +.form-signin { + max-width: 330px; + padding: 15px; + margin: 0 auto; +} +.form-signin .form-signin-heading, +.form-signin .form-control { + position: relative; + height: auto; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 10px; +} +.form-signin .form-control:focus { + z-index: 2; +} +.form-signin input[type="text"] { + margin-bottom: -1px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.form-signin input[type="password"] { + margin-bottom: 10px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} +.form-control { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075); + -webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s; +} +.btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: 400; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.btn-block { + display: block; + width: 100%; +} +.btn-lg { + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +.btn-primary { + color: #fff; + background-color: #337ab7; + border-color: #2e6da4; +} +.invalid-credentials { + border: 1px solid #ff0000; + background-color: rgba(255, 0, 0, 0.2); + color: #880000; + padding: 10px; + border-radius: 5px; + margin: 20px; +} diff --git a/delete.php b/delete.php index df51bee..c329919 100644 --- a/delete.php +++ b/delete.php @@ -1,13 +1,13 @@ +if (isset($_GET['batch_del'])) { + if (empty($_POST['selected_keys'])) { + die('No keys to delete'); + } + $keys = json_decode($_POST['selected_keys']); + + foreach ($keys as $key) { + $redis->del($key); + } + + die('?view&s=' . $server['id'] . '&d=' . $server['db'] . '&key=' . urlencode($keys[0])); +} + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5ef83df --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + phpredisadmin: + build: . + environment: + - ADMIN_USER=admin + - ADMIN_PASS=admin + - REDIS_1_HOST=redis + - REDIS_1_PORT=6379 + links: + - redis + ports: + - "80:80" + + redis: + image: redis + command: --loglevel verbose diff --git a/edit.php b/edit.php index e50da25..998012c 100644 --- a/edit.php +++ b/edit.php @@ -2,8 +2,7 @@ require_once 'includes/common.inc.php'; - - +global $redis, $config, $csrfToken, $server; // Are we editing or creating a new key? $edit = false; @@ -35,70 +34,83 @@ die('ERROR: could not encode value'); } - // String - if ($_POST['type'] == 'string') { - $redis->set($key, $value); - } - - // Hash - else if (($_POST['type'] == 'hash') && isset($_POST['hkey'])) { - if (strlen($_POST['hkey']) > $config['maxkeylen']) { - die('ERROR: Your hash key is to long (max length is '.$config['maxkeylen'].')'); + try { + // String + if ($_POST['type'] == 'string') { + $redis->set($key, $value); } - if ($edit && !$redis->hExists($key, input_convert($_POST['hkey']))) { - $redis->hDel($key, input_convert($_GET['hkey'])); + // Hash + else if (($_POST['type'] == 'hash') && isset($_POST['hkey'])) { + if (strlen($_POST['hkey']) > $config['maxkeylen']) { + die('ERROR: Your hash key is to long (max length is '.$config['maxkeylen'].')'); + } + + if ($edit && !$redis->hExists($key, input_convert($_POST['hkey']))) { + $redis->hDel($key, input_convert($_GET['hkey'])); + } + + $redis->hSet($key, input_convert($_POST['hkey']), $value); } - $redis->hSet($key, input_convert($_POST['hkey']), $value); - } + // List + else if (($_POST['type'] == 'list') && isset($_POST['index'])) { + $size = $redis->lLen($key); + + if (($_POST['index'] == '') || + ($_POST['index'] == $size)) { + // Push it at the end + $redis->rPush($key, $value); + } else if ($_POST['index'] == -1) { + // Push it at the start + $redis->lPush($key, $value); + } else if (($_POST['index'] >= 0) && + ($_POST['index'] < $size)) { + // Overwrite an index + $redis->lSet($key, input_convert($_POST['index']), $value); + } else { + die('ERROR: Out of bounds index'); + } + } - // List - else if (($_POST['type'] == 'list') && isset($_POST['index'])) { - $size = $redis->lLen($key); - - if (($_POST['index'] == '') || - ($_POST['index'] == $size) || - ($_POST['index'] == -1)) { - // Push it at the end - $redis->rPush($key, $value); - } else if (($_POST['index'] >= 0) && - ($_POST['index'] < $size)) { - // Overwrite an index - $redis->lSet($key, input_convert($_POST['index']), $value); - } else { - die('ERROR: Out of bounds index'); + // Set + else if ($_POST['type'] == 'set') { + if ($_POST['value'] != $_POST['oldvalue']) { + // The only way to edit a Set value is to add it and remove the old value. + $redis->sRem($key, encodeOrDecode('save', $key, input_convert($_POST['oldvalue']))); + $redis->sAdd($key, $value); + } } - } - // Set - else if ($_POST['type'] == 'set') { - if ($_POST['value'] != $_POST['oldvalue']) { - // The only way to edit a Set value is to add it and remove the old value. - $redis->sRem($key, encodeOrDecode('save', $key, input_convert($_POST['oldvalue']))); - $redis->sAdd($key, $value); + // ZSet + else if (($_POST['type'] == 'zset') && isset($_POST['score']) && is_numeric($_POST['score'])) { + // The only way to edit a ZSet value is to add it and remove the old value. + $redis->zRem($key, encodeOrDecode('save', $key, input_convert($_POST['oldvalue']))); + $redis->zAdd($key, input_convert($_POST['score']), $value); } - } - // ZSet - else if (($_POST['type'] == 'zset') && isset($_POST['score'])) { - // The only way to edit a ZSet value is to add it and remove the old value. - $redis->zRem($key, encodeOrDecode('save', $key, input_convert($_POST['oldvalue']))); - $redis->zAdd($key, input_convert($_POST['score']), $value); - } + // Refresh the top so the key tree is updated. + require 'includes/header.inc.php'; - // Refresh the top so the key tree is updated. - require 'includes/header.inc.php'; + ?> + + - - +
+

getMessage() ?>

+
+

-
+ +

@@ -182,13 +195,11 @@ -

-

+?> \ No newline at end of file diff --git a/export.php b/export.php index 69c15c4..d17839e 100644 --- a/export.php +++ b/export.php @@ -2,19 +2,24 @@ require_once 'includes/common.inc.php'; - - +global $redis, $config, $csrfToken, $server; // Export to redis-cli commands -function export_redis($key) { +function export_redis($key, $filter = false, $transform = false) { + global $redis; $type = $redis->type($key); + // we rename the keys as necessary + if($filter !== false && $transform !== false) + $outputKey = str_replace($filter, $transform, $key); + else + $outputKey = $key; // String if ($type == 'string') { - echo 'SET "',addslashes($key),'" "',addslashes($redis->get($key)),'"',PHP_EOL; + echo 'SET "',addslashes($outputKey),'" "',addslashes($redis->get($key)),'"',PHP_EOL; } // Hash @@ -22,7 +27,7 @@ function export_redis($key) { $values = $redis->hGetAll($key); foreach ($values as $k => $v) { - echo 'HSET "',addslashes($key),'" "',addslashes($k),'" "',addslashes($v),'"',PHP_EOL; + echo 'HSET "',addslashes($outputKey),'" "',addslashes($k),'" "',addslashes($v),'"',PHP_EOL; } } @@ -31,7 +36,7 @@ function export_redis($key) { $size = $redis->lLen($key); for ($i = 0; $i < $size; ++$i) { - echo 'RPUSH "',addslashes($key),'" "',addslashes($redis->lIndex($key, $i)),'"',PHP_EOL; + echo 'RPUSH "',addslashes($outputKey),'" "',addslashes($redis->lIndex($key, $i)),'"',PHP_EOL; } } @@ -40,7 +45,7 @@ function export_redis($key) { $values = $redis->sMembers($key); foreach ($values as $v) { - echo 'SADD "',addslashes($key),'" "',addslashes($v),'"',PHP_EOL; + echo 'SADD "',addslashes($outputKey),'" "',addslashes($v),'"',PHP_EOL; } } @@ -51,7 +56,7 @@ function export_redis($key) { foreach ($values as $v) { $s = $redis->zScore($key, $v); - echo 'ZADD "',addslashes($key),'" ',$s,' "',addslashes($v),'"',PHP_EOL; + echo 'ZADD "',addslashes($outputKey),'" ',$s,' "',addslashes($v),'"',PHP_EOL; } } } @@ -64,7 +69,6 @@ function export_json($key) { $type = $redis->type($key); - // String if ($type == 'string') { $value = $redis->get($key); @@ -116,9 +120,12 @@ function export_json($key) { header('Content-type: '.$ct.'; charset=utf-8'); header('Content-Disposition: inline; filename="export.'.$ext.'"'); + $filter = !empty($_POST['filter']) ? trim($_POST['filter']) : false; + $transform = !empty($_POST['transform']) ? trim($_POST['transform']) : false; // JSON if ($_POST['type'] == 'json') { + // Single key if (isset($_GET['key'])) { echo json_encode(export_json($_GET['key'])); @@ -127,7 +134,18 @@ function export_json($key) { $vals = array(); foreach ($keys as $key) { - $vals[$key] = export_json($key); + + // if we have a filter and no match, nothing to do + if($filter !== false && stripos($key, $filter) === false) + continue; + + // we rename the keys as necessary + if($filter !== false && $transform !== false) + $outputKey = str_replace($filter, $transform, $key); + else + $outputKey = $key; + + $vals[$outputKey] = export_json($key); } echo json_encode($vals); @@ -136,6 +154,7 @@ function export_json($key) { // Redis Commands else { + // Single key if (isset($_GET['key'])) { export_redis($_GET['key']); @@ -143,7 +162,12 @@ function export_json($key) { $keys = $redis->keys('*'); foreach ($keys as $key) { - export_redis($key); + + // if we have a filter and no match, we skip + if($filter !== false && stripos($key, $filter) === false) + continue; + + export_redis($key, $filter, $transform); } } } @@ -163,7 +187,8 @@ function export_json($key) { ?>

Export

-
+ +

@@ -173,9 +198,19 @@ function export_json($key) {

-

+ +

+ + +

+ +

+ + +

+ + -

flushdb(); diff --git a/import.php b/import.php index 9dbca76..5e5a89e 100644 --- a/import.php +++ b/import.php @@ -1,9 +1,7 @@

Import

-
+ +

-

-

$v) { - unset($process[$key][$k]); - - if (is_array($v)) { - $process[$key][stripslashes($k)] = $v; - $process[] = &$process[$key][stripslashes($k)]; - } else { - $process[$key][stripslashes($k)] = stripslashes($v); - } - } + if (isset($_SESSION['phpredisadmin_csrf'])) { + $csrfToken = $_SESSION['phpredisadmin_csrf']; + } else { + $csrfToken = bin2hex(random_bytes(16)); + $_SESSION['phpredisadmin_csrf'] = $csrfToken; } - - unset($process); +} else { + $csrfToken = 'nosession'; } - +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if ($_POST['csrf'] !== $csrfToken) { + die('bad csrf token'); + } +} // These includes are needed by each script. @@ -46,7 +43,6 @@ $i = 0; } - if (isset($_GET['s']) && is_numeric($_GET['s']) && ($_GET['s'] < count($config['servers']))) { $i = $_GET['s']; } @@ -55,10 +51,8 @@ $server['id'] = $i; $server['charset'] = isset($server['charset']) && $server['charset'] ? $server['charset'] : false; - mb_internal_encoding('utf-8'); - if (isset($login, $login['servers'])) { if (array_search($i, $login['servers']) === false) { die('You are not allowed to access this database.'); @@ -102,7 +96,19 @@ } if (!isset($server['scansize'])) { - $server['scansize'] = $config['scansize']; + if (isset($config['scansize'])) { + $server['scansize'] = $config['scansize']; + } else { + $server['scansize'] = 1000; + } +} + +if (!isset($server['scanmax'])) { + if (isset($config['scanmax'])) { + $server['scanmax'] = $config['scanmax']; + } else { + $server['scanmax'] = 0; + } } if (!isset($server['serialization'])) { @@ -111,17 +117,29 @@ } } +if (!isset($config['hideEmptyDBs'])) { + $config['hideEmptyDBs'] = false; +} + +if (!isset($config['showEmptyNamespaceAsKey'])) { + $config['showEmptyNamespaceAsKey'] = false; +} + +if (!isset($server['scheme']) || empty($server['scheme'])) { + $server['scheme'] = 'tcp'; +} + // Setup a connection to Redis. -if(isset($server['scheme']) && $server['scheme'] === 'unix' && $server['path']) { +if ($server['scheme'] === 'unix' && $server['path']) { $redis = new Predis\Client(array('scheme' => 'unix', 'path' => $server['path'])); } else { - $redis = !$server['port'] ? new Predis\Client($server['host']) : new Predis\Client('tcp://'.$server['host'].':'.$server['port']); + $redis = !$server['port'] ? new Predis\Client($server['host']) : new Predis\Client($server['scheme'].'://'.$server['host'].':'.$server['port']); } try { $redis->connect(); } catch (Predis\CommunicationException $exception) { - $redis = false; + die('ERROR: ' . $exception->getMessage()); } if (isset($server['auth'])) { @@ -130,11 +148,12 @@ } } +if (!isset($config['login']) && !empty($config['login_as_acl_auth'])) { + require_once PHPREDIS_ADMIN_PATH . '/includes/login_acl.inc.php'; +} if ($server['db'] != 0) { if (!$redis->select($server['db'])) { die('ERROR: Selecting database failed ('.$server['host'].':'.$server['port'].','.$server['db'].')'); } } - -?> diff --git a/includes/config.environment.inc.php b/includes/config.environment.inc.php index bb7c76c..06a06fe 100644 --- a/includes/config.environment.inc.php +++ b/includes/config.environment.inc.php @@ -2,6 +2,19 @@ include 'config.sample.inc.php'; +// get configs from environment variables +$config['cookie_auth'] = getenv('COOKIE_AUTH') ?: false; +$config['count_elements_page'] = getenv('COUNT_ELEMENTS_PAGE') ?: 100; +$config['faster'] = getenv('FASTER') ?: true; +$config['filter'] = getenv('FILTER') ?: '*'; +$config['hideEmptyDBs'] = getenv('HIDE_EMPTY_DBS') ?: false; +$config['keys'] = getenv('KEYS') ?: false; +$config['maxkeylen'] = getenv('MAX_KEY_LEN') ?: 100; +$config['scansize'] = getenv('SCAN_SIZE') ?: 1000; +$config['scanmax'] = getenv('SCAN_MAX') ?: 1000; +$config['seperator'] = getenv('SEPERATOR') ?: ':'; +$config['showEmptyNamespaceAsKey'] = getenv('SHOW_EMPTY_NAMESPACE_AS_KEY') ?: false; + $admin_user = getenv('ADMIN_USER'); $admin_pass = getenv('ADMIN_PASS'); @@ -14,14 +27,22 @@ } $i=1; +$config['servers'] = array(); -while (TRUE) { +while (true) { $prefix = 'REDIS_' . $i . '_'; $server_name = getenv($prefix . 'NAME'); $server_host = getenv($prefix . 'HOST'); $server_port = getenv($prefix . 'PORT'); + $server_scheme = getenv($prefix . 'SCHEME'); + if (getenv($prefix . 'AUTH_FILE') !== false) { + $server_auth = file_get_contents(getenv($prefix . 'AUTH_FILE')); + } else { + $server_auth = getenv($prefix . 'AUTH'); + } + $server_databases = getenv($prefix . 'DATABASES'); if (empty($server_host)) { break; @@ -31,16 +52,35 @@ $server_name = $server_host; } - if (empty($server_port)) { + if (empty($server_auth)) { + $server_auth = ""; + } + + if (empty($server_port) && strpos($server_host, ':') === false) { $server_port = 6379; } + if (empty($server_scheme)) { + $server_scheme = 'tcp'; + } + $config['servers'][] = array( - 'name' => $server_name, - 'host' => $server_host, - 'port' => $server_port, - 'filter' => '*', + 'name' => $server_name, + 'host' => $server_host, + 'port' => $server_port, + 'filter' => $config['filter'], + 'scansize' => $config['scansize'], + 'scanmax' => $config['scanmax'], + 'scheme' => $server_scheme, ); + if (!empty($server_auth)) { + $config['servers'][$i-1]['auth'] = $server_auth; + } + + if (!empty($server_databases)) { + $config['servers'][$i-1]['databases'] = $server_databases; + } + $i++; } diff --git a/includes/config.sample.inc.php b/includes/config.sample.inc.php index 321c8d6..923a60c 100644 --- a/includes/config.sample.inc.php +++ b/includes/config.sample.inc.php @@ -9,7 +9,8 @@ 'port' => 6379, 'filter' => '*', 'scheme' => 'tcp', // Optional. Connection scheme. 'tcp' - for TCP connection, 'unix' - for connection by unix domain socket - 'path' => '' // Optional. Path to unix domain socket. Uses only if 'scheme' => 'unix'. Example: '/var/run/redis/redis.sock' + 'path' => '', // Optional. Path to unix domain socket. Uses only if 'scheme' => 'unix'. Example: '/var/run/redis/redis.sock' + 'hide' => false, // Optional. Override global setting. Hide empty databases in the database list. // Optional Redis authentication. //'auth' => 'redispasswordhere' // Warning: The password is sent in plain-text to the Redis server. @@ -31,13 +32,17 @@ 'flush' => false, // Set to true to enable the flushdb button for this instance. 'charset' => 'cp1251', // Keys and values are stored in redis using this encoding (default utf-8). 'keys' => false, // Use the old KEYS command instead of SCAN to fetch all keys for this server (default uses config default). - 'scansize' => 1000 // How many entries to fetch using each SCAN command for this server (default uses config default). + 'scansize' => 1000, // How many entries to fetch using each SCAN command for this server (default uses config default). + 'scanmax' => 1000, // In each query, SCAN command may be executed several times. To shorten the duration, it is recommended to limit the total number of entries to fetch (default uses config default). ),*/ ), 'seperator' => ':', + 'showEmptyNamespaceAsKey' => false, + // Hide empty databases in the database list (global, valid for all servers unless set at server level) + 'hideEmptyDBs' => false, // Uncomment to show less information and make phpRedisAdmin fire less commands to the Redis server. Recommended for a really busy Redis server. //'faster' => true, @@ -56,6 +61,15 @@ ) ),*/ + // Uncomment to enable login as ACL authentication (won't work if 'login' or 'auth' is also used) + // Only support using one server at this moment. + // If you set the default user off, browsers will be redirected to login page. + // The user and password will be stored in browser as plaintext so using HTTPS is strongly recommended. + // 'login_as_acl_auth' => true, + + // Use HTML form/cookie-based auth instead of HTTP Basic/Digest auth + 'cookie_auth' => false, + /*'serialization' => array( 'foo*' => array( // Match like KEYS @@ -76,7 +90,8 @@ 'keys' => false, // How many entries to fetch using each SCAN command. - 'scansize' => 1000 -); + 'scansize' => 1000, -?> + // The total number of entries to fetch. Set to 0 or -1 for no limit. + 'scanmax' => 0 +); diff --git a/includes/functions.inc.php b/includes/functions.inc.php index 89368e3..589d324 100644 --- a/includes/functions.inc.php +++ b/includes/functions.inc.php @@ -26,39 +26,30 @@ function input_convert($str) { } -function format_ago($time, $ago = false) { +function format_time($time) { $minute = 60; $hour = $minute * 60; $day = $hour * 24; $when = $time; - if ($when >= 0) - $suffix = 'ago'; - else { - $when = -$when; - $suffix = 'in the future'; - } - if ($when > $day) { - $when = round($when / $day); - $what = 'day'; + $tmpday = floor($when / $day); + $tmphour = floor(($when / $hour) - (24*$tmpday)); + $tmpminute = floor(($when / $minute) - (24*60*$tmpday) - ($tmphour * 60)); + $tmpsec = floor($when - (24*60*60*$tmpday) - ($tmphour * 60 * 60) - ($tmpminute * 60)); + return sprintf("%d day%s %d hour%s %d min%s %d sec%s",$tmpday,($tmpday != 1) ? 's' : '',$tmphour,($tmphour != 1) ? 's' : '',$tmpminute,($tmpminute != 1) ? 's' : '',$tmpsec,($tmpsec != 1) ? 's' : ''); } else if ($when > $hour) { - $when = round($when / $hour); - $what = 'hour'; + $tmphour = floor($when / $hour); + $tmpminute = floor(($when / $minute) - ($tmphour * 60)); + $tmpsec = floor($when - ($tmphour * 60 * 60) - ($tmpminute * 60)); + return sprintf("%d hour%s %d min%s %d sec%s",$tmphour,($tmphour != 1) ? 's' : '',$tmpminute,($tmpminute != 1) ? 's' : '',$tmpsec,($tmpsec != 1) ? 's' : ''); } else if ($when > $minute) { - $when = round($when / $minute); - $what = 'minute'; - } else { - $what = 'second'; - } - - if ($when != 1) $what .= 's'; - - if ($ago) { - return "$when $what $suffix"; + $tmpminute = floor($when / $minute); + $tmpsec = floor($when - ($tmpminute * 60)); + return sprintf("%d min%s %d sec%s",$tmpminute,($tmpminute != 1) ? 's' : '',$tmpsec,($tmpsec != 1) ? 's' : ''); } else { - return "$when $what"; + return sprintf("%d sec%s",$when,($when != 1) ? 's' : ''); } } @@ -74,6 +65,15 @@ function format_size($size) { } +function format_ttl($seconds) { + if ($seconds > 60) { + return sprintf('%d (%s)', $seconds, format_time($seconds)); + } else { + return $seconds; + } +} + + function str_rand($length) { $r = ''; @@ -101,3 +101,6 @@ function encodeOrDecode($action, $key, $data) { return $data; } +function getRelativePath($base) { + return substr($_SERVER['REQUEST_URI'], strpos($_SERVER['REQUEST_URI'], $base)); +} diff --git a/includes/header.inc.php b/includes/header.inc.php index aa618f1..7679bd6 100644 --- a/includes/header.inc.php +++ b/includes/header.inc.php @@ -4,6 +4,7 @@ header('Content-Type: text/html; charset=utf-8'); header('Cache-Control: private'); +header('X-Frame-Options: sameorigin'); ?> @@ -35,5 +36,9 @@ + + diff --git a/includes/login.inc.php b/includes/login.inc.php index 633bd94..8cfb26d 100644 --- a/includes/login.inc.php +++ b/includes/login.inc.php @@ -1,63 +1,144 @@ 1, + 'nc' => 1, + 'cnonce' => 1, + 'qop' => 1, + 'username' => 1, + 'uri' => 1, + 'response' => 1 + ); -$needed_parts = array( - 'nonce' => 1, - 'nc' => 1, - 'cnonce' => 1, - 'qop' => 1, - 'username' => 1, - 'uri' => 1, - 'response' => 1 - ); + $data = array(); + $keys = implode('|', array_keys($needed_parts)); -$data = array(); -$keys = implode('|', array_keys($needed_parts)); + preg_match_all('/('.$keys.')=(?:([\'"])([^\2]+?)\2|([^\s,]+))/', $_SERVER['PHP_AUTH_DIGEST'], $matches, PREG_SET_ORDER); -preg_match_all('/('.$keys.')=(?:([\'"])([^\2]+?)\2|([^\s,]+))/', $_SERVER['PHP_AUTH_DIGEST'], $matches, PREG_SET_ORDER); + foreach ($matches as $m) { + $data[$m[1]] = $m[3] ? $m[3] : $m[4]; + unset($needed_parts[$m[1]]); + } -foreach ($matches as $m) { - $data[$m[1]] = $m[3] ? $m[3] : $m[4]; - unset($needed_parts[$m[1]]); -} + if (!empty($needed_parts)) { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); + die; + } -if (!empty($needed_parts)) { - header('HTTP/1.1 401 Unauthorized'); - header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); - die; -} + if (!isset($config['login'][$data['username']])) { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); + die('Invalid username and/or password combination.'); + } -if (!isset($config['login'][$data['username']])) { - header('HTTP/1.1 401 Unauthorized'); - header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); - die('Invalid username and/or password combination.'); -} + $login = $config['login'][$data['username']]; + $login['name'] = $data['username']; -$login = $config['login'][$data['username']]; -$login['name'] = $data['username']; + $password = md5($login['name'].':'.$realm.':'.$login['password']); -$password = md5($login['name'].':'.$realm.':'.$login['password']); + $response = md5($password.':'.$data['nonce'].':'.$data['nc'].':'.$data['cnonce'].':'.$data['qop'].':'.md5($_SERVER['REQUEST_METHOD'].':'.$data['uri'])); -$response = md5($password.':'.$data['nonce'].':'.$data['nc'].':'.$data['cnonce'].':'.$data['qop'].':'.md5($_SERVER['REQUEST_METHOD'].':'.$data['uri'])); + if ($data['response'] !== $response) { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); + die('Invalid username and/or password combination.'); + } + + return $login; +} + +// Perform auth using a standard HTML
submission and cookies to save login state +function authCookie() +{ + global $config; + + $generateCookieHash = function($username) use ($config) { + if (!isset($config['login'][$username])) { + throw new \RuntimeException("Invalid username"); + } + + // Storing this value client-side so we need to be careful that it + // doesn't reveal anything nor can be guessed. + // Using SHA512 because MD5, SHA1 are both now considered broken + return hash( + 'sha512', + implode(':', array( + $username, + $_SERVER['HTTP_USER_AGENT'], + $_SERVER['REMOTE_ADDR'], + $config['login'][$username]['password'], + )) + ); + }; + + if (!empty($_COOKIE['phpRedisAdminLogin'])) { + // We have a cookie; is it correct? + // Cookie value looks like "username:password-hash" + $cookieVal = explode(':', $_COOKIE['phpRedisAdminLogin']); + if (count($cookieVal) === 2) { + list($username, $cookieHash) = $cookieVal; + if (isset($config['login'][$username])) { + $userData = $config['login'][$username]; + $expectedHash = $generateCookieHash($username); + + if ($cookieHash === $expectedHash) { + // Correct username & password + return $userData; + } + } + } + } + + if (isset($_POST['username'], $_POST['password'])) { + // Login form submitted; correctly? + if (isset($config['login'][$_POST['username']])) { + $userData = $config['login'][$_POST['username']]; + if ($_POST['password'] === $userData['password']) { + // Correct username & password. Set cookie and redirect to home page + $cookieValue = $_POST['username'] . ':' . $generateCookieHash($_POST['username']); + setcookie('phpRedisAdminLogin', $cookieValue); + + // This should be an absolute URL, but that's a bit of a pain to generate; this will work + header("Location: index.php"); + die(); + } + } + } + + // If we're here, we don't have a valid login cookie and we don't have a + // valid form submission, so redirect to the login page if we aren't + // already on that page + if (!defined('LOGIN_PAGE')) { + header("Location: login.php"); + die(); + } + + // We must be on the login page without a valid cookie or submission + return null; +} -if ($data['response'] != $response) { - header('HTTP/1.1 401 Unauthorized'); - header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.uniqid().'",opaque="'.$opaque.'"'); - die('Invalid username and/or password combination.'); +if (!empty($config['cookie_auth'])) { + $login = authCookie(); +} else { + $login = authHttpDigest(); } ?> diff --git a/includes/login_acl.inc.php b/includes/login_acl.inc.php new file mode 100644 index 0000000..e8b7d72 --- /dev/null +++ b/includes/login_acl.inc.php @@ -0,0 +1,100 @@ +auth($login['username'], $login['password']); + } catch (Predis\Response\ServerException $exception) { + return false; + } + return true; +} + +// This fill will perform HTTP basic authentication. The authentication data will be sent and stored as plaintext +// Please make sure to use HTTPS proxy such as Apache or Nginx to prevent traffic eavesdropping. +function authHttpBasic() +{ + $realm = 'phpRedisAdmin'; + + if (!isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) { + try { + global $redis; + $redis->ping(); + } catch (Predis\Response\ServerException $exception) { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Basic realm="' . $realm . '"'); + die('NOAUTH -- Authentication required'); + } + } + + $login = [ + 'username' => $_SERVER['PHP_AUTH_USER'], + 'password' => $_SERVER['PHP_AUTH_PW'], + ]; + + if (!authCheck($login)) { + header('HTTP/1.1 401 Unauthorized'); + header('WWW-Authenticate: Basic realm="' . $realm . '"'); + die('NOAUTH -- Authentication required'); + } + + return $login; +} + +// Perform auth using a standard HTML submission and cookies to save login state +function authCookie() +{ + if (!empty($_COOKIE['phpRedisAdminLogin'])) { + // We have a cookie; is it correct? + // Cookie value looks like "username:password-hash" + $login = explode(':', $_COOKIE['phpRedisAdminLogin'], 2); + if (count($login) === 2) { + $login = [ + 'username' => $login[0], + 'password' => $login[1], + ]; + if (authCheck($login)) { + return $login; + } + } + } + + if (isset($_POST['username'], $_POST['password'])) { + // Login form submitted; correctly? + $login = [ + 'username' => $_POST['username'], + 'password' => $_POST['password'], + ]; + + if (authCheck($login)) { + setcookie('phpRedisAdminLogin', $login['username'] . ':' . $login['password']); + // This should be an absolute URL, but that's a bit of a pain to generate; this will work + header("Location: index.php"); + die(); + } + } + + try { + global $redis; + $redis->ping(); + } catch (Predis\Response\ServerException $exception) { + // If we're here, we don't have a valid login cookie and we don't have a + // valid form submission, so redirect to the login page if we aren't + // already on that page + if (!defined('LOGIN_PAGE')) { + header("Location: login.php"); + die(); + } + } + + // We must be on the login page without a valid cookie or submission + return null; +} + +if (!empty($config['cookie_auth'])) { + $login = authCookie(); +} else { + $login = authHttpBasic(); +} diff --git a/includes/page.inc.php b/includes/page.inc.php index 2f1a0e9..2941e68 100644 --- a/includes/page.inc.php +++ b/includes/page.inc.php @@ -1,6 +1,5 @@ array('common'), 'js' => array('jquery') diff --git a/index.php b/index.php index f56c572..ea509c7 100644 --- a/index.php +++ b/index.php @@ -1,6 +1,7 @@ scan($next, 'MATCH', $server['filter'], 'COUNT', $server['scansize']); - $next = $r[0]; $keys = array_merge($keys, $r[1]); - if ($next == 0) { break; } + if ($server['scanmax'] > 0 && count($keys) >= $server['scanmax']) { + break; + } } } @@ -33,7 +34,11 @@ continue; } - $key = explode($server['seperator'], $key); + $key = explode($server['seperator'], $key); //@todo: may be separator ? + if ($config['showEmptyNamespaceAsKey'] && $key[count($key) - 1] == '') { + array_pop($key); + $key[count($key) - 1] .= ':'; + } // $d will be a reference to the current namespace. $d = &$namespaces; @@ -77,7 +82,13 @@ function print_namespace($item, $name, $fullkey, $islast) { // Get the number of items in the key. if (!isset($config['faster']) || !$config['faster']) { - switch ($redis->type($fullkey)) { + $type = ''; + try { + $type = $redis->type($fullkey); + } catch (\Predis\Response\ServerException $th) { + $class[] = 'empty'; + } + switch ($type) { case 'hash': $len = $redis->hLen($fullkey); break; @@ -96,10 +107,15 @@ function print_namespace($item, $name, $fullkey, $islast) { } } + if (empty($name) && $name != '0') { + $name = ''; + $class[] = 'empty'; + } ?> > - () + + ()
  •  () - [X] + [X]