From 955189d08a0bc3ae03f12d80e4909ec62dcd15b7 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 16 Jul 2026 15:33:46 -0400 Subject: [PATCH] Air-gap: remove dead CDN reference + in-app /docs - Removed a dead IE<9-only html5shim script tag pointing at a domain that no longer resolves. - New GET /docs (index) and /docs/:slug routes render this project's own README, DEPLOYMENT, API.md, docs/*.md, and directory_spec.md server-side via marked -- so the documentation is readable from the running app with no route to GitHub Pages, where it otherwise only lives. Public, no auth, rate-limited (middleware/rate_limit.js) like the other public routes. - .dockerignore/Dockerfile.openldap updated to copy DEPLOYMENT.md, API.md, directory_spec.md, and docs/ into the image, mirroring the existing tos.md -> /tos.md convention. --- .dockerignore | 8 +++- Dockerfile.openldap | 8 ++++ nodejs/app.js | 5 +++ nodejs/middleware/rate_limit.js | 8 ++++ nodejs/routes/docs.js | 71 +++++++++++++++++++++++++++++++++ nodejs/views/bottom.ejs | 3 ++ nodejs/views/docs_index.ejs | 24 +++++++++++ nodejs/views/docs_page.ejs | 29 ++++++++++++++ nodejs/views/top.ejs | 6 --- 9 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 nodejs/routes/docs.js create mode 100644 nodejs/views/docs_index.ejs create mode 100644 nodejs/views/docs_page.ejs diff --git a/.dockerignore b/.dockerignore index e689124..083ca37 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,9 +9,15 @@ .claude *.md # README.md and tos.md are both read at runtime (tos.md is loaded by -# routes/index.js at boot), so they must stay in the build context. +# routes/index.js at boot). DEPLOYMENT.md/API.md/directory_spec.md/docs/*.md +# are read at runtime too, by routes/docs.js -- all must stay in the build +# context. !README.md !tos.md +!DEPLOYMENT.md +!API.md +!directory_spec.md +!docs/**/*.md # Tests nodejs/tests/ diff --git a/Dockerfile.openldap b/Dockerfile.openldap index 5671889..22857f9 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -95,6 +95,14 @@ COPY nodejs/public ./public # level above the nodejs/ app dir). Without this the app crashes on startup. COPY tos.md /tos.md +# Documentation, served in-app at /docs (routes/docs.js) so it's readable +# without internet access. Same flattened-path convention as tos.md above. +COPY README.md /README.md +COPY DEPLOYMENT.md /DEPLOYMENT.md +COPY API.md /API.md +COPY directory_spec.md /directory_spec.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 10c3044..3d20421 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -68,6 +68,11 @@ app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'} // Routes for front end content. app.use('/', require('./routes/index')); +// 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')); + // API routes for authentication. app.use('/api/auth', require('./routes/auth')); diff --git a/nodejs/middleware/rate_limit.js b/nodejs/middleware/rate_limit.js index 8bf3406..4318cf5 100644 --- a/nodejs/middleware/rate_limit.js +++ b/nodejs/middleware/rate_limit.js @@ -40,3 +40,11 @@ exports.invite = rateLimit({ limit: 20, handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }), }); + +// Public, unauthenticated, reads from disk on every request -- generous +// since it's just docs, but still throttled per IP. +exports.docs = rateLimit({ + windowMs: 60 * 1000, + limit: 120, + handler: handler({ name: 'RateLimitError', message: 'Too many requests, try again later.' }), +}); diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js new file mode 100644 index 0000000..98cae6c --- /dev/null +++ b/nodejs/routes/docs.js @@ -0,0 +1,71 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const router = require('express').Router(); +const {marked} = require('marked'); +const conf = require('@simpleworkjs/conf'); +const buildInfo = require('../utils/build_info'); +const rateLimit = require('../middleware/rate_limit'); + +const values = { + title: conf.environment !== 'production' ? `dev` : '', + titleIcon: conf.environment !== 'production' ? `` : '', + name: conf.name, + ...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. +// docs/deployment.md is deliberately excluded -- it's just a stub pointing +// back at the root DEPLOYMENT.md (see docs/deployment.md itself), which is +// already covered by the "deployment" entry. +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')}, + ldap: {title: 'LDAP', file: path.join(__dirname, '../../docs/ldap.md')}, + oauth: {title: 'OAuth', file: path.join(__dirname, '../../docs/oauth.md')}, + configuration: {title: 'Configuration', file: path.join(__dirname, '../../docs/configuration.md')}, + 'directory-spec': {title: 'Directory Spec (draft)', file: path.join(__dirname, '../../directory_spec.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(rateLimit.docs); + +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/views/bottom.ejs b/nodejs/views/bottom.ejs index 920347c..05dd604 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..43c28d3 --- /dev/null +++ b/nodejs/views/docs_index.ejs @@ -0,0 +1,24 @@ +<%- include('top') %> +
+
+
+
+ Documentation +
+
+

+ A local copy of this project's documentation, readable from the + running app -- no internet access required. +

+ +
+
+
+
+<%- include('bottom') %> diff --git a/nodejs/views/docs_page.ejs b/nodejs/views/docs_page.ejs new file mode 100644 index 0000000..7af0fc2 --- /dev/null +++ b/nodejs/views/docs_page.ejs @@ -0,0 +1,29 @@ +<%- include('top') %> +
+
+
+
+ Documentation +
+
+ <% docs.forEach(function(doc){ %> + + <%= doc.title %> + + <% }) %> +
+
+
+
+
+
+ <%= docTitle %> +
+
+ <%- docHtml %> +
+
+
+
+<%- include('bottom') %> diff --git a/nodejs/views/top.ejs b/nodejs/views/top.ejs index f3cd9f6..eb7e7dd 100755 --- a/nodejs/views/top.ejs +++ b/nodejs/views/top.ejs @@ -24,12 +24,6 @@ - - - -