← 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;