diff --git a/.dockerignore b/.dockerignore index 8b934b9..30a7dfb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,8 +14,6 @@ ops/cookbooks/ ops/roles/ ops/proxy.service -# Docs site (served via GitHub Pages, not from the image). -docs/ .github/ # Git + editor + secrets. @@ -24,8 +22,13 @@ docs/ # nodejs/utils/build_info.js), then it's discarded before the final stage. # It never ends up in the final image. .gitignore +# docs/ and the doc files below ARE needed in the image now — served in-app +# at /docs (routes/docs.js) so they're readable without internet access. *.md !README.md +!DEPLOYMENT.md +!nodejs/api.md +!docs/**/*.md secrets.js secrets.json *.env diff --git a/Dockerfile b/Dockerfile index 942bf0d..49ad531 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,6 +106,15 @@ COPY nodejs/services ./services COPY nodejs/utils ./utils COPY nodejs/views ./views COPY nodejs/public ./public +COPY nodejs/api.md ./api.md + +# Documentation, served in-app at /docs (routes/docs.js) so it's readable +# without internet access. README.md/DEPLOYMENT.md land one level above the +# flattened /app (mirrors sso-manager-node's tos.md -> /tos.md convention); +# docs/ mirrors the repo's own top-level docs/ folder. +COPY README.md /README.md +COPY DEPLOYMENT.md /DEPLOYMENT.md +COPY docs /docs # Baked commit hash from the gitinfo stage (see build_info.js). COPY --from=gitinfo /commit.txt ./.build_commit diff --git a/nodejs/app.js b/nodejs/app.js index 9bb69a8..2040bd9 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -77,6 +77,11 @@ app.use('/__proxy_auth', require('./routes/host_auth')); // Routes for front end content. app.use('/', require('./routes/render')); +// Local, in-app copy of the project's documentation (README, DEPLOYMENT, +// api.md, docs/*) -- public, no auth, so it's readable even by a locked-out +// admin or an air-gapped operator with no route to GitHub Pages. +app.use('/docs', require('./routes/docs')); + // Routes for API app.use('/api', require('./routes/api')); diff --git a/nodejs/models/dynamic_record.js b/nodejs/models/dynamic_record.js index c2c7762..385a168 100644 --- a/nodejs/models/dynamic_record.js +++ b/nodejs/models/dynamic_record.js @@ -76,8 +76,17 @@ class DynamicRecord extends Table{ } } - // Resolve the public IP once, then reconcile every record to it. + // Resolve the public IP once, then reconcile every record to it. Checked + // BEFORE the public-IP lookup: on a stock install with zero dynamic + // records configured, this runs on a timer regardless (services/dynamic_dns.js) + // -- without this guard it would still reach out to the public-IP + // resolvers (utils/public_ip.js) every cycle for nothing, which is + // exactly the kind of always-on external call an air-gapped deployment + // can't have. static async refreshAll(){ + let records = await this.listDetail(); + if(!records.length) return {count: 0}; + let ip; try{ ip = await getPublicIp(); @@ -86,7 +95,6 @@ class DynamicRecord extends Table{ return {error: error.message}; } - let records = await this.listDetail(); for(let record of records){ await record.apply(ip); } diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index ab4543f..d045479 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -25,6 +25,7 @@ "jquery": "^4.0.0", "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", + "marked": "^9.1.6", "model-redis": "^1.5.0", "moment": "^2.30.1", "mustache": "^4.2.0", @@ -1410,6 +1411,18 @@ "integrity": "sha512-TyPFnk3kp9kplKPjWtYYbezYzj+Xe47bGu3YZs2Jg3xxLUw/ny0AHL252jpR1c8q32vIQxWK8qLpFZ+qHHC0MQ==", "license": "MIT" }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 51e74d6..e1ec60c 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -36,6 +36,7 @@ "jquery": "^4.0.0", "ldapts": "^8.1.8", "linux-sys-user": "^1.2.0", + "marked": "^9.1.6", "model-redis": "^1.5.0", "moment": "^2.30.1", "mustache": "^4.2.0", diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js new file mode 100644 index 0000000..e99fa6e --- /dev/null +++ b/nodejs/routes/docs.js @@ -0,0 +1,78 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const router = require('express').Router(); +const {rateLimit} = require('express-rate-limit'); +const {marked} = require('marked'); +const conf = require('@simpleworkjs/conf'); +const buildInfo = require('../utils/build_info'); + +// Public, unauthenticated, and reads from disk on every request -- throttle +// per IP so it can't be used to hammer the filesystem (mirrors the pattern +// in routes/auth.js/routes/host.js), generous since this is just docs. +const docsLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 120, + standardHeaders: true, + legacyHeaders: false, + message: {name: 'TooManyRequests', message: 'Too many requests, please try again later.'}, +}); + +const values = { + title: conf.environment !== 'production' ? `dev` : '', + titleIcon: conf.environment !== 'production' ? `` : '', + ...buildInfo, +}; + +// Full local copy of the project's documentation, rendered server-side -- +// so an operator running air-gapped (no route to GitHub Pages, where this +// content otherwise only lives) can still read it from the running app. +// An explicit slug -> file allowlist, never a user-suppliable path, so +// there's no way to make this read outside the doc set below. +const DOCS = { + overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, + deployment: {title: 'Deployment', file: path.join(__dirname, '../../DEPLOYMENT.md')}, + api: {title: 'API Reference', file: path.join(__dirname, '../api.md')}, + installation: {title: 'Installation', file: path.join(__dirname, '../../docs/installation.md')}, + architecture: {title: 'Architecture', file: path.join(__dirname, '../../docs/architecture.md')}, + docker: {title: 'Docker', file: path.join(__dirname, '../../docs/docker.md')}, + contributing: {title: 'Contributing', file: path.join(__dirname, '../../docs/contributing.md')}, +}; + +const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})); + +// README.md links its screenshots as repo-relative "docs/images/...", which +// only resolves correctly on GitHub. Serve that same folder here and rewrite +// the rendered markup to point at it absolutely, so the images work when +// read from /docs/overview too. +router.use('/images', require('express').static(path.join(__dirname, '../../docs/images'))); +function fixImagePaths(html) { + return html.replace(/(["(])docs\/images\//g, '$1/docs/images/'); +} + +router.use(docsLimiter); + +router.get('/', function(req, res) { + res.render('docs_index', {...values, docs: docList}); +}); + +router.get('/:slug', function(req, res, next) { + const doc = DOCS[req.params.slug]; + if (!doc) return next({status: 404, message: 'Doc not found'}); + + try { + const content = fs.readFileSync(doc.file, 'utf8'); + res.render('docs_page', { + ...values, + docs: docList, + currentSlug: req.params.slug, + docTitle: doc.title, + docHtml: fixImagePaths(marked(content)), + }); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 17235ba..6f592fc 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -80,7 +80,4 @@ router.get('/login/*splat', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); }); -router.get('/test', async function(req, res, next) { - res.render('test', {...values, redirect: req.query.redirect}); -}); module.exports = router; diff --git a/nodejs/views/bottom.ejs b/nodejs/views/bottom.ejs index 2ff65f0..905359d 100755 --- a/nodejs/views/bottom.ejs +++ b/nodejs/views/bottom.ejs @@ -10,6 +10,9 @@ MIT License + + Docs + GitHub diff --git a/nodejs/views/docs_index.ejs b/nodejs/views/docs_index.ejs new file mode 100644 index 0000000..0dca9cf --- /dev/null +++ b/nodejs/views/docs_index.ejs @@ -0,0 +1,26 @@ +<%- include('top') %> + + + + + + Documentation + + + + A local copy of this project's documentation, readable from the + running app -- no internet access required. + + + <% docs.forEach(function(doc){ %> + + <%= doc.title %> + + <% }) %> + + + + + + +<%- include('bottom') %> diff --git a/nodejs/views/docs_page.ejs b/nodejs/views/docs_page.ejs new file mode 100644 index 0000000..2cc9d64 --- /dev/null +++ b/nodejs/views/docs_page.ejs @@ -0,0 +1,31 @@ +<%- include('top') %> + + + + + + Documentation + + + <% docs.forEach(function(doc){ %> + + <%= doc.title %> + + <% }) %> + + + + + + + <%= docTitle %> + + + <%- docHtml %> + + + + + +<%- include('bottom') %> diff --git a/nodejs/views/test.ejs b/nodejs/views/test.ejs deleted file mode 100644 index 6e0fc1c..0000000 --- a/nodejs/views/test.ejs +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - Document - - - - - {{ item }}: {{ done }} - - - - \ No newline at end of file diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index 68599d8..06cdcfd 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -24,12 +24,6 @@ - - - -
+ A local copy of this project's documentation, readable from the + running app -- no internet access required. +