← back to Inline Editor
src/middleware.js
220 lines
/**
* @dw/inline-editor — Express middleware for the DW sister-site fleet.
*
* Usage:
* const inlineEditor = require('@dw/inline-editor');
* app.use(inlineEditor({
* site: 'corkwallcovering.com',
* uploadDir: path.join(__dirname, 'public', 'uploads'),
* authMode: 'dev' | 'prod', // 'dev' uses AUTH_DEV_SECRET
* }));
*
* What it does:
* 1. Mounts /_inline/editor.js, /_inline/editor.css (static)
* 2. Mounts /api/_inline/{state,save,upload,revert,log} (auth-gated except state)
* 3. Wraps res.send for text/html responses — walks DOM via cheerio, applies
* matching overrides from PG, and injects the editor bootstrap.
*/
const path = require('path');
const fs = require('fs');
const express = require('express');
const cookieParser = require('cookie-parser');
const cheerio = require('cheerio');
const multer = require('multer');
const crypto = require('crypto');
const { sanitizeByKind } = require('./sanitize');
const { authMiddleware, readAdminFromCookie } = require('./auth');
const { getOverridesFor, saveOverride, getLogFor, recordUpload, rateLimitCheck } = require('./db');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
function inlineEditor(opts = {}) {
const {
site,
uploadDir,
authMode = process.env.NODE_ENV === 'production' ? 'prod' : 'dev',
devSecret = process.env.AUTH_DEV_SECRET || 'dev-secret-change-me',
publicPem = process.env.AUTH_PUBLIC_PEM || null,
jwksUrl = process.env.AUTH_JWKS_URL || null,
cacheTtlMs = 30_000,
maxUploadBytes = 5 * 1024 * 1024,
} = opts;
if (!site) throw new Error('inlineEditor: { site } is required');
const authOpts = { authMode, devSecret, publicPem, jwksUrl };
const cache = new Map(); // key=site|path → { overrides, ts }
const router = express.Router();
router.use(cookieParser());
// -------- static editor assets --------
router.use('/_inline', express.static(PUBLIC_DIR, { maxAge: '1h' }));
// -------- /api/_inline/state — read overrides for a path (admin-only) --------
router.get('/api/_inline/state', authMiddleware(authOpts), (req, res) => {
const p = req.query.path || '/';
const overrides = getOverridesFor(site, p);
res.json({ site, path: p, overrides });
});
// -------- /api/_inline/log --------
router.get('/api/_inline/log', authMiddleware(authOpts), (req, res) => {
const p = req.query.path || '/';
res.json({ site, path: p, log: getLogFor(site, p) });
});
// -------- /api/_inline/save --------
router.post('/api/_inline/save', express.json({ limit: '256kb' }), authMiddleware(authOpts), (req, res) => {
try {
const { path: p, selector, kind, value } = req.body || {};
if (!p || !selector || !kind || value == null) return res.status(400).json({ error: 'missing_fields' });
if (!isStableSelector(selector)) return res.status(400).json({ error: 'unstable_selector', selector });
const rate = rateLimitCheck(req.editor.email);
if (rate.blocked) return res.status(429).json({ error: 'rate_limited', count: rate.count });
const clean = sanitizeByKind(kind, value, { site });
saveOverride({
site, path: p, selector, kind, value: clean,
editorEmail: req.editor.email,
editorIp: clientIp(req),
editorUa: (req.get('user-agent') || '').slice(0, 500)
});
cache.delete(site + '|' + p);
res.json({ ok: true, cleaned: clean });
} catch (e) {
res.status(500).json({ error: 'save_failed', detail: String(e.message || e) });
}
});
// -------- /api/_inline/revert -------- v1 semantics: revert = drop override
// (restores source-HTML content). Log row records the deletion. Step-back UI
// is v2 (walk the log and selectively restore prior overrides).
router.post('/api/_inline/revert', express.json(), authMiddleware(authOpts), (req, res) => {
try {
const { path: p, selector } = req.body || {};
if (!p || !selector) return res.status(400).json({ error: 'missing_fields' });
const log = getLogFor(site, p, 200);
const target = log.find(e => e.selector === selector);
const kind = target ? target.kind : 'text';
const { deleteOverride } = require('./db');
deleteOverride({ site, path: p, selector, kind,
editorEmail: req.editor.email,
editorIp: clientIp(req),
editorUa: (req.get('user-agent') || '').slice(0, 500)
});
cache.delete(site + '|' + p);
res.json({ ok: true, deleted: true });
} catch (e) {
res.status(500).json({ error: 'revert_failed', detail: String(e.message || e) });
}
});
// -------- /api/_inline/upload --------
if (uploadDir) {
try { fs.mkdirSync(uploadDir, { recursive: true }); } catch {}
const upload = multer({
storage: multer.diskStorage({
destination: uploadDir,
filename: (_req, file, cb) => {
const ext = (file.originalname.match(/\.[a-zA-Z0-9]+$/) || [''])[0].toLowerCase();
const safeExt = /^\.(jpg|jpeg|png|gif|webp|svg)$/i.test(ext) ? ext : '.bin';
cb(null, crypto.randomBytes(8).toString('hex') + safeExt);
}
}),
limits: { fileSize: maxUploadBytes, files: 1 },
fileFilter: (_req, file, cb) => cb(null, /^image\//.test(file.mimetype))
});
router.post('/api/_inline/upload',
authMiddleware(authOpts),
upload.single('image'),
(req, res) => {
if (!req.file) return res.status(400).json({ error: 'no_file' });
const url = '/uploads/' + req.file.filename;
recordUpload({
site,
url,
absPath: req.file.path,
mimeType: req.file.mimetype,
bytes: req.file.size,
uploadedBy: req.editor.email,
uploaderIp: clientIp(req)
});
res.json({ ok: true, url, bytes: req.file.size, mime: req.file.mimetype });
}
);
}
// -------- HTML rewriter (wraps res.send) --------
router.use((req, res, next) => {
const originalSend = res.send;
res.send = function (body) {
try {
const ct = res.get('Content-Type') || '';
if (ct.includes('text/html') && typeof body === 'string' && body.length < 2_000_000) {
const editor = readAdminFromCookie(req, authOpts);
const reqPath = req.path || '/';
const cacheKey = site + '|' + reqPath;
let cached = cache.get(cacheKey);
if (!cached || Date.now() - cached.ts > cacheTtlMs) {
cached = { overrides: getOverridesFor(site, reqPath), ts: Date.now() };
cache.set(cacheKey, cached);
}
body = applyOverrides(body, cached.overrides, { admin: !!editor, editor, site, path: reqPath });
}
} catch (e) {
// Fail-open: never break the page on rewriter error
console.error('[inline-editor] rewrite failed:', e.message);
}
return originalSend.call(this, body);
};
next();
});
return router;
}
function clientIp(req) {
const xff = req.get('x-forwarded-for');
if (xff) return xff.split(',')[0].trim();
return req.ip || null;
}
// Selectors we accept: #id, [data-edit-id="..."], or structural like "main > section:nth-of-type(2) > h1"
function isStableSelector(s) {
if (typeof s !== 'string' || s.length > 500) return false;
// Strip the only function-form we allow — :nth-of-type(N) and :nth-child(N) — then validate remainder
const stripped = s.replace(/:nth-(?:of-type|child)\(\d+\)/g, '');
// Allowed in remainder: #, ., [, ], =, ", ', word chars, hyphen, space, >, +, ~, :, ,
if (!/^[#.\[\]\w\-\=\"\'\s>+~:,]+$/.test(stripped)) return false;
// Block obvious nasties even though regex above already excludes them
if (/[<\\\(\){}|]/.test(stripped)) return false;
return true;
}
function applyOverrides(html, overrides, ctx) {
const $ = cheerio.load(html, { decodeEntities: false });
for (const [selector, ov] of Object.entries(overrides || {})) {
try {
const el = $(selector);
if (!el.length) continue;
if (ov.kind === 'text') el.first().text(ov.value);
else if (ov.kind === 'html') el.first().html(ov.value);
else if (ov.kind === 'image') el.first().attr('src', ov.value);
else if (ov.kind === 'image_alt') el.first().attr('alt', ov.value);
else if (ov.kind === 'href') el.first().attr('href', ov.value);
} catch (e) { /* skip a broken selector */ }
}
// Inject editor bootstrap when admin is signed in
if (ctx.admin) {
$('head').append(`<link rel="stylesheet" href="/_inline/editor.css">`);
$('body').append(`<script>window.__DW_INLINE__=${JSON.stringify({ site: ctx.site, path: ctx.path, editor: ctx.editor.email })};</script>`);
$('body').append(`<script src="/_inline/editor.js" defer></script>`);
}
return $.html();
}
module.exports = inlineEditor;
module.exports.inlineEditor = inlineEditor;