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); +}