diff --git a/.dockerignore b/.dockerignore
index c381112..75ba39b 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,7 @@
.*
+!.htaccess
*.md
build.txt
docker-compose.dev.yml
modules/config.php
+modules/config.backup-*.php
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 5ff1da8..857d20a 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -28,7 +28,9 @@ jobs:
DOCKERHUB_ENABLED: ${{ secrets.DOCKERHUB_USERNAME != '' && secrets.DOCKERHUB_TOKEN != '' }}
steps:
- name: Checkout
- uses: actions/checkout@v5
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
+ with:
+ persist-credentials: false
- name: Select target registries
id: images
run: |
@@ -41,7 +43,7 @@ jobs:
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Docker meta
id: meta
- uses: docker/metadata-action@v6
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
with:
# GitHub Container Registry is the primary image location;
# Docker Hub is included only when its secrets are configured
@@ -57,24 +59,24 @@ jobs:
type=semver,pattern={{major}}
type=sha
- name: Set up QEMU
- uses: docker/setup-qemu-action@v4
+ uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v4
+ uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- name: Login to GHCR
if: github.event_name != 'pull_request'
- uses: docker/login-action@v4
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to DockerHub
if: github.event_name != 'pull_request' && env.DOCKERHUB_ENABLED == 'true'
- uses: docker/login-action@v4
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
- uses: docker/build-push-action@v7
+ uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
platforms: linux/amd64,linux/arm64
diff --git a/.gitignore b/.gitignore
index 491eace..118e716 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
modules/config.php
.idea/
nanote.txt
-nanoNodeMonitor
\ No newline at end of file
+nanoNodeMonitor
+modules/config.backup-*.php
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..fba5eaf
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,10 @@
+# Hide repository metadata and docs on installs that git-clone the project
+# into the webroot. Uses only mod_alias (AllowOverride FileInfo), answering
+# 404 as if the files were not there. The page itself (index.php, api.php,
+# templates/, static/) is unaffected.
+
+ RedirectMatch 404 "/\.(git|github)(/|$)"
+ RedirectMatch 404 "/\.(gitignore|dockerignore)$"
+ RedirectMatch 404 "(?i)\.(md|yml|yaml|sh|ps1)$"
+ RedirectMatch 404 "/Dockerfile$"
+
diff --git a/AGENT.md b/AGENT.md
index 5d0a894..ec9581d 100644
--- a/AGENT.md
+++ b/AGENT.md
@@ -43,6 +43,7 @@ modules/
templates/index.hbs Handlebars template rendered client-side with api.php data
static/ Vendored CSS/JS, themes, images (no build step)
Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/config.php
+.htaccess (root, modules/, scripts/) Apache access rules (see Security conventions)
```
## Architecture / request flow
@@ -57,6 +58,10 @@ Dockerfile, entry.sh Docker image; entry.sh symlinks /opt/nanoNodeMonitor/con
node version, block counts, peers, confirmation-time percentiles, account
balance/representative/weight, host system load/memory/uptime, telemetry, and sync
percentage. Cache TTL (default 30 s) shields the node from request bursts.
+ `FileCache` and `ApcuCache` override `fetch()` single-flight: only one request
+ rebuilds an expired entry (flock on `.lock` / `apcu_add` lock), the
+ others wait up to 10 s and re-read; if locking fails they fall back to plain
+ read-callback-write.
4. **RPC layer** — `functions_rpc.php` has one function per RPC action; all go through
`postCurl()`, which POSTs JSON to `http://$nanoNodeRPCIP:$nanoNodeRPCPort` and dies
with an HTTP 503 (`myError()`) when the node is unreachable.
@@ -74,7 +79,8 @@ Configs carry a `$configVersion` schema marker (`0` for legacy files,
`CONFIG_VERSION` in `constants.php` is current). `scripts/migrate-config.php`
upgrades old configs (run automatically by `entry.sh` on container start): it
loads defaults + the user's config, applies sequential per-version migration
-steps, backs up the old file, and writes a minimal config containing only
+steps, backs up the old file (as `config.backup-.php` next to it, with
+the original's permission bits, so a web server never serves it as text), and writes a minimal config containing only
non-default values. When a config change needs migration (renamed setting,
retired option value), bump `CONFIG_VERSION` and add a step to the
`$migrations` array keyed by the new version — steps run once per config, so
@@ -82,7 +88,14 @@ one-time rules (like the v1 dark→modern theme move) never re-fire.
The script token-scans the config source **before executing it** and skips
configs containing custom PHP logic (control flow, superglobals like
-`$_SERVER`, `die`/`exit`, output, includes). Never weaken this: operators run
+`$_SERVER`, `die`/`exit`, output, includes). After that denylist an
+**allowlist** pass (`nnm_config_is_plain()`) accepts only assignments of
+literals, arrays, `true`/`false`/`null`, defined constants, variables and
+`= ; , ( ) [ ] . - +`; any other token (function calls such as `getenv()`,
+backticks, `new`, `eval`, `?:`, `fn`, `match`, heredocs, `"$interpolation"`)
+marks the config as scripted. `(` directly after a value is treated as a call.
+This keeps runtime-computed values (e.g. secrets read from the environment)
+from being evaluated as root and baked into the file. Never weaken this: operators run
hand-crafted configs with host-based switching and request guards, and
flattening one would bake in a single branch of that logic. Scripted configs
silence the startup notice by declaring a literal `$configVersion = N;` at the
@@ -95,6 +108,27 @@ These were deliberate hardening decisions — do not regress them:
- **Never use `unserialize()` on cache or request data.** `FileCache` stores JSON
(a malicious serialized payload in a predictable cache path would otherwise allow
PHP object injection). `RedisCache` also uses JSON.
+- **`FileCache` only trusts a safe base directory**: a real directory (not a
+ symlink), owned by the effective user (`posix_geteuid()`) and not world-writable;
+ cache files that are symlinks are never read or written. Otherwise it behaves
+ like `NullCache` (logged via `error_log`), never fatal. Mode/owner checks are
+ skipped on Windows.
+- **Web access rules**: `modules/.htaccess` and `scripts/.htaccess` deny all
+ requests, so nothing the browser needs may live there (the page loads only
+ `index.php`, `api.php`, `templates/` and `static/`). The root `.htaccess` 404s
+ `.git/`, `.github/`, dotfiles, `*.md/yml/yaml/sh/ps1` and `Dockerfile` using only
+ `mod_alias` (keep it to `AllowOverride FileInfo` directives so it cannot 500 the
+ page on restricted hosts). `.dockerignore` must keep `!.htaccess`.
+- `index.php` sends `X-Content-Type-Options: nosniff` and
+ `Referrer-Policy: strict-origin-when-cross-origin`. No `X-Frame-Options` /
+ `frame-ancestors`: operators embed the page.
+- Client-side errors are shown via `textContent` (`showError()` in `index.js`),
+ never by assigning response data to `innerHTML`.
+- Docker: `entry.sh` only adds traverse (`o+x`) to `/opt` and makes the config dir
+ 755; never chmod or broaden mounts beyond `/opt/nanoNodeMonitor` (legacy
+ `-v ~:/opt` mounts make `/opt` the operator's home directory).
+- Workflow actions are pinned to full commit SHAs (`# vX.Y.Z` comment);
+ Dependabot updates them.
- **Escape all config/host-derived values in templates** with the `e()` helper
(`htmlspecialchars`, ENT_QUOTES). Account values embedded in third-party image URLs
are additionally `rawurlencode()`d. Values echoed into inline JS use `(int)` casts or
diff --git a/README.md b/README.md
index 487a5d6..e3c01e2 100644
--- a/README.md
+++ b/README.md
@@ -29,11 +29,18 @@ They are also pushed to Docker Hub as a secondary, legacy location:
#### Standalone
- sudo docker run -d -p 80:80 -v ~:/opt --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor
+ sudo docker run -d -p 80:80 -v ~/nanoNodeMonitor:/opt/nanoNodeMonitor --restart=unless-stopped ghcr.io/gigaionllc/nanonodemonitor
This will create a directory called _nanoNodeMonitor_ inside your home directory with the _config.php_ inside it.
Edit it according to your needs and you're good to go!
+> **Upgrading from an older setup?** Earlier versions of this README mounted the whole
+> home directory (`-v ~:/opt`). That still works, but it gives the monitor container
+> access to everything in your home directory (in the Compose setup that includes the
+> node's wallet files). Switch to the narrower mounts shown here; your config stays in
+> the same place (`~/nanoNodeMonitor/config.php`), and in Compose the node's data stays
+> in `~/Nano` as before.
+
#### Docker Compose
1. Create a directory called _nano_ and go inside it: `mkdir nano && cd nano`
@@ -49,7 +56,7 @@ services:
ports:
- "80:80"
volumes:
- - "~:/opt"
+ - "~/nanoNodeMonitor:/opt/nanoNodeMonitor"
node:
image: "nanocurrency/nano:TAG"
restart: "unless-stopped"
@@ -57,7 +64,7 @@ services:
- "7075:7075"
- "127.0.0.1:7076:7076"
volumes:
- - "~:/root"
+ - "~/Nano:/root/Nano"
```
3. Nice! Now execute `sudo docker-compose up -d` to start everything.
@@ -67,6 +74,19 @@ services:
6. Done!
+## Node RPC safety
+
+The monitor only uses read-only RPC actions (`version`, `block_count`, `peers`,
+`account_balance`, `telemetry`, ...). It never needs wallet or control actions, so:
+
+- Keep `enable_control = false` in the node's `config-rpc.toml`.
+- Bind RPC to localhost (`::1` / `127.0.0.1`) or to the Docker network only. Never
+ publish port 7076 on a public interface (the Compose example above uses
+ `127.0.0.1:7076:7076` for that reason).
+- When the monitor reaches the node by container name, any other container on the same
+ Docker network can reach the RPC as well, which is one more reason to keep
+ `enable_control` off.
+
## Manual Installation
### Prerequisites
diff --git a/entry.sh b/entry.sh
index a3c01fc..5fd051c 100644
--- a/entry.sh
+++ b/entry.sh
@@ -19,8 +19,11 @@ ln -s $monitordir/config.php /var/www/html/modules/config.php
# the old file next to it on the volume). Never blocks the web server.
php /var/www/html/scripts/migrate-config.php || echo "WARNING: config migration failed, starting with existing config."
-# change folder rights so www-data can read
-chmod 755 /opt
+# let www-data reach the config. /opt only needs traverse (o+x), not
+# read/list: with the legacy `-v ~:/opt` mount /opt *is* the host home
+# directory, which must not be made listable for other users.
+chmod o+x /opt
+chmod 755 "${monitordir}"
# start apache
apache2-foreground
diff --git a/index.php b/index.php
index 25f34cc..2124abb 100644
--- a/index.php
+++ b/index.php
@@ -1,4 +1,9 @@
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/modules/cache/ApcuCache.php b/modules/cache/ApcuCache.php
index 697a1bc..ac2bb0a 100644
--- a/modules/cache/ApcuCache.php
+++ b/modules/cache/ApcuCache.php
@@ -3,6 +3,11 @@
class ApcuCache extends Cache {
private $ttl;
+ // how long to wait for another request's rebuild before rebuilding anyway
+ const LOCK_WAIT_SECONDS = 10;
+ // safety expiry of the rebuild lock should its holder die unexpectedly
+ const LOCK_TTL = 30;
+
public function __construct(array $options = array()) {
$this->ttl = array_key_exists('ttl', $options) ? $options['ttl'] : 30;
}
@@ -16,4 +21,57 @@ public function read($key) {
public function write($key, $data) {
return apcu_store($key, $data, $this->ttl);
}
+
+ /**
+ * Single-flight fetch: apcu_add() on ".lock" is an atomic lock, so
+ * only one request rebuilds an expired entry while the others wait
+ * (bounded) for its result. The lock is also released if the callback
+ * exits (e.g. node down -> HTTP 503). If the wait times out, this falls
+ * back to the plain read-callback-write behaviour.
+ */
+ public function fetch($key, $callback) {
+ $data = $this->read($key);
+ if (!is_null($data)) return $data;
+
+ $lockKey = $key . '.lock';
+ $token = uniqid('', true);
+ $locked = apcu_add($lockKey, $token, self::LOCK_TTL);
+
+ if (!$locked) {
+ $deadline = microtime(true) + self::LOCK_WAIT_SECONDS;
+ while (!$locked && microtime(true) < $deadline) {
+ usleep(50000);
+ $data = $this->read($key);
+ if (!is_null($data)) return $data;
+ $locked = apcu_add($lockKey, $token, self::LOCK_TTL);
+ }
+ }
+
+ if (!$locked) {
+ $data = $callback();
+ $this->write($key, $data);
+ return $data;
+ }
+
+ $released = false;
+ $release = function () use ($lockKey, $token, &$released) {
+ if (!$released && apcu_fetch($lockKey) === $token) {
+ apcu_delete($lockKey);
+ }
+ $released = true;
+ };
+ register_shutdown_function($release);
+
+ try {
+ // another request may have rebuilt the entry just before we locked
+ $data = $this->read($key);
+ if (is_null($data)) {
+ $data = $callback();
+ $this->write($key, $data);
+ }
+ } finally {
+ $release();
+ }
+ return $data;
+ }
}
diff --git a/modules/cache/FileCache.php b/modules/cache/FileCache.php
index f6b5308..bd11da4 100644
--- a/modules/cache/FileCache.php
+++ b/modules/cache/FileCache.php
@@ -28,6 +28,17 @@ class FileCache extends Cache
*/
private $ttl = 30;
+ /**
+ * Result of the base directory safety check (null = not checked yet).
+ * @var bool|null
+ */
+ private $cache_dir_safe = null;
+
+ /**
+ * Maximum time in seconds to wait for another request's rebuild.
+ */
+ const LOCK_WAIT_SECONDS = 10;
+
/**
* Creates a FileCache object
*
@@ -56,9 +67,12 @@ public function __construct(array $options = array())
*/
public function read($id)
{
+ if (!$this->isCacheDirectorySafe()) {
+ return NULL;
+ }
$file_name = $this->getFileName($id);
- if (!is_file($file_name) || !is_readable($file_name)) {
+ if (is_link($file_name) || !is_file($file_name) || !is_readable($file_name)) {
return NULL;
}
@@ -97,13 +111,13 @@ public function delete($id)
*/
public function write($id, $data)
{
- $dir = $this->getDirectory($id);
- if (!is_dir($dir)) {
- if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
- return false;
- }
+ if (!$this->isCacheDirectorySafe() || !$this->ensureDirectory($id)) {
+ return false;
}
$file_name = $this->getFileName($id);
+ if (is_link($file_name)) {
+ return false;
+ }
$lifetime = time() + $this->ttl;
$encoded = json_encode($data);
$result = file_put_contents($file_name, $lifetime . PHP_EOL . $encoded, LOCK_EX);
@@ -113,10 +127,140 @@ public function write($id, $data)
return true;
}
+ /**
+ * Fetches an entry, rebuilding it with $callback when missing/expired.
+ *
+ * Single-flight: only one request rebuilds an expired entry. The rebuild
+ * runs under an exclusive flock() on ".lock"; concurrent
+ * requests wait (bounded) for it and then read the fresh entry instead
+ * of all hitting the node at once. The lock is released automatically
+ * if the callback exits (e.g. node down -> HTTP 503). If locking is not
+ * possible, or the wait times out, this falls back to the plain
+ * read-callback-write behaviour.
+ *
+ * @param string $id
+ * @param callable $callback
+ */
+ public function fetch($id, $callback)
+ {
+ $data = $this->read($id);
+ if (!is_null($data)) {
+ return $data;
+ }
+
+ $lock = $this->acquireLock($id);
+ if ($lock === null) {
+ $data = $callback();
+ $this->write($id, $data);
+ return $data;
+ }
+
+ try {
+ // another request may have rebuilt the entry while we waited
+ $data = $this->read($id);
+ if (is_null($data)) {
+ $data = $callback();
+ $this->write($id, $data);
+ }
+ } finally {
+ flock($lock, LOCK_UN);
+ fclose($lock);
+ }
+ return $data;
+ }
+
//------------------------------------------------
// PRIVATE METHODS
//------------------------------------------------
+ /**
+ * Takes the rebuild lock for an entry.
+ *
+ * @param string $id
+ *
+ * @return resource|null locked handle, or null when locking is not possible
+ */
+ protected function acquireLock($id)
+ {
+ if (!$this->isCacheDirectorySafe() || !$this->ensureDirectory($id)) {
+ return null;
+ }
+ $lock_name = $this->getFileName($id) . '.lock';
+ if (is_link($lock_name)) {
+ return null;
+ }
+ $handle = @fopen($lock_name, 'c');
+ if ($handle === false) {
+ return null;
+ }
+ $deadline = microtime(true) + self::LOCK_WAIT_SECONDS;
+ do {
+ if (flock($handle, LOCK_EX | LOCK_NB)) {
+ return $handle;
+ }
+ usleep(50000);
+ } while (microtime(true) < $deadline);
+
+ fclose($handle);
+ return null;
+ }
+
+ /**
+ * Creates the entry's sub directory inside the (already checked) base dir.
+ *
+ * @param string $id
+ *
+ * @return bool
+ */
+ protected function ensureDirectory($id)
+ {
+ $dir = $this->getDirectory($id);
+ if (!is_dir($dir)) {
+ if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Checks that the base cache directory can be trusted.
+ *
+ * The default directory lives in the shared system temp dir, where another
+ * local user could pre-create it (world-writable, or as a symlink) and
+ * plant or redirect cache files. Only use it if it is a real directory
+ * owned by the effective user of this process and not world-writable.
+ * Otherwise the cache is disabled (NullCache behaviour), never fatal.
+ * POSIX mode/owner checks are skipped on Windows, where they do not apply.
+ *
+ * @return bool
+ */
+ protected function isCacheDirectorySafe()
+ {
+ if ($this->cache_dir_safe !== null) {
+ return $this->cache_dir_safe;
+ }
+ $base = $this->getCacheDirectory();
+ if (!is_link($base) && !is_dir($base)) {
+ @mkdir($base, 0700, true);
+ }
+ clearstatcache(true, $base);
+
+ $safe = !is_link($base) && is_dir($base);
+ if ($safe && PHP_OS_FAMILY !== 'Windows') {
+ $perms = @fileperms($base);
+ $safe = $perms !== false && ($perms & 0002) === 0;
+ if ($safe && function_exists('posix_geteuid')) {
+ $safe = @fileowner($base) === posix_geteuid();
+ }
+ }
+ if (!$safe) {
+ error_log("nanoNodeMonitor: cache directory $base is not safe to use "
+ . "(symlink, world-writable or owned by another user) - file cache disabled");
+ }
+ return $this->cache_dir_safe = $safe;
+ }
+
/**
* Fetches a directory to store the cache data
*
diff --git a/modules/constants.php b/modules/constants.php
index ccb37bb..c6c9fc7 100644
--- a/modules/constants.php
+++ b/modules/constants.php
@@ -1,7 +1,7 @@
true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
- CURLOPT_TIMEOUT => 30,
+ CURLOPT_TIMEOUT => EXTERNAL_TIMEOUT,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "GET",
@@ -170,7 +170,7 @@ function getVersionInformation($latestVersion)
if ( version_compare($currentVersion, $latestVersion) < 0 )
{
- $versionInfo .= "
A new version " . $latestVersion;
+ $versionInfo .= "
A new version " . e($latestVersion);
$versionInfo .= " is available on ";
$versionInfo .= "GitHub.";
}
diff --git a/scripts/.htaccess b/scripts/.htaccess
new file mode 100644
index 0000000..8b880a1
--- /dev/null
+++ b/scripts/.htaccess
@@ -0,0 +1,10 @@
+# Nothing in this directory is meant to be requested by a browser: PHP files
+# here are only included by index.php/api.php (or run from the CLI), and
+# modules/ holds the operator's config.php and its backups.
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/scripts/migrate-config.php b/scripts/migrate-config.php
index e980477..5c9201d 100644
--- a/scripts/migrate-config.php
+++ b/scripts/migrate-config.php
@@ -62,7 +62,66 @@ function nnm_config_is_scripted($source, &$reason)
return true;
}
}
- return false;
+
+ return !nnm_config_is_plain($source, $reason);
+}
+
+/**
+ * Allowlist pass: a config is "plain" (safe to execute and flatten) only if
+ * it consists of variable assignments of literals, arrays, constants and
+ * simple arithmetic/concatenation. Anything else - function calls, getenv(),
+ * backticks, new, eval, ?:, fn, match, heredocs, inline HTML... - means
+ * the file is treated as scripted and left untouched, so values the operator
+ * deliberately computes at runtime (e.g. secrets from the environment) are
+ * never evaluated here and baked into the file as literals.
+ */
+function nnm_config_is_plain($source, &$reason)
+{
+ $allowedTokens = array(
+ T_OPEN_TAG, T_CLOSE_TAG, T_WHITESPACE, T_COMMENT, T_DOC_COMMENT,
+ T_VARIABLE, T_CONSTANT_ENCAPSED_STRING, T_LNUMBER, T_DNUMBER,
+ T_ARRAY, T_DOUBLE_ARROW, T_STRING,
+ );
+ $allowedChars = array('=', ';', ',', '(', ')', '[', ']', '.', '-', '+');
+ // "(" may open array(...) or group an expression, but never call
+ // something: after a value ($var, 'string', CONST, ")" or "]") it
+ // would be a (variable) function call such as 'phpinfo'() or $f().
+ $callableBefore = array(T_VARIABLE, T_CONSTANT_ENCAPSED_STRING, T_STRING, ')', ']');
+
+ $prev = null; // previous significant token id (int) or char
+ foreach (token_get_all($source) as $token) {
+ if (is_array($token)) {
+ list($id, $text) = $token;
+ if (!in_array($id, $allowedTokens, true)) {
+ $reason = token_name($id) . " '" . trim($text) . "'";
+ return false;
+ }
+ if ($id === T_VARIABLE && preg_match('/^\$(GLOBALS|_[A-Z]+)$/', $text)) {
+ $reason = $text;
+ return false;
+ }
+ if ($id === T_STRING && !in_array(strtolower($text), array('true', 'false', 'null'), true)
+ && !defined($text)) {
+ $reason = "'$text'";
+ return false;
+ }
+ if (in_array($id, array(T_WHITESPACE, T_COMMENT, T_DOC_COMMENT), true)) {
+ continue;
+ }
+ $prev = $id;
+ continue;
+ }
+ if (!in_array($token, $allowedChars, true)) {
+ $reason = "'$token'";
+ return false;
+ }
+ if ($token === '(' && in_array($prev, $callableBefore, true)) {
+ $reason = 'function call';
+ return false;
+ }
+ $prev = $token;
+ }
+ return true;
}
$source = file_get_contents($configPath);
@@ -212,13 +271,19 @@ function nnm_load_config_state($modulesDir, $configPath)
exit(0);
}
-$backupPath = $configPath . '.bak-' . date('Ymd-His');
+// The backup keeps a .php extension so a web server executes it (printing
+// nothing) instead of serving the config source as plain text when it sits
+// in a webroot (manual installs keep config.php in modules/). It also keeps
+// the original's permission bits instead of the default umask.
+$backupPath = dirname($configPath) . '/config.backup-' . date('Ymd-His') . '.php';
if (!copy($configPath, $backupPath)) {
exit("ERROR: could not back up config.php to $backupPath - aborting.\n");
}
+@chmod($backupPath, fileperms($configPath) & 0777);
echo " - old config backed up to " . basename($backupPath) . "\n";
if (file_put_contents($configPath, $out, LOCK_EX) === false) {
+ copy($backupPath, $configPath);
exit("ERROR: could not write $configPath. Old config restored from backup.\n");
}
diff --git a/static/js/index.js b/static/js/index.js
index d6eef87..2441878 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -80,10 +80,30 @@ function updateStats(){
.catch(function (error) {
console.log('FAIL', error);
if(error.response){
- document.getElementById("content").innerHTML = error.response.data;
+ showError(error.response.data);
}
})
.finally(function () {
setTimeout(updateStats, GLOBAL_REFRESH * 1000);
});
}
+
+// Render an API error as plain text in the .myError box. The response body is
+// never inserted as markup: only its text is extracted (myError() sends
+// 'message
'; proxies/CDNs may send full pages).
+function showError(data){
+ var text = '';
+ if (typeof data === 'string') {
+ try {
+ text = new DOMParser().parseFromString(data, 'text/html').body.textContent;
+ } catch (e) {
+ text = '';
+ }
+ }
+ var box = document.createElement('div');
+ box.className = 'myError';
+ box.textContent = (text && text.trim()) ? text : 'API error';
+ var content = document.getElementById('content');
+ content.textContent = '';
+ content.appendChild(box);
+}