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 @@ - - - -