157 lines
5.5 KiB
JavaScript
157 lines
5.5 KiB
JavaScript
/**
|
|
* Template Handler Middleware
|
|
* Handles injecting CSP nonces into HTML templates
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const winston = require('winston');
|
|
const { generateRichSnippet, escapeHtmlAttr } = require('./rich_snippet');
|
|
|
|
// Cache for HTML templates
|
|
const templateCache = {};
|
|
|
|
function injectRichSnippetIntoHtml(html, snippet) {
|
|
if (!snippet) return html;
|
|
|
|
const metaTags = [
|
|
`<meta name="description" content="${escapeHtmlAttr(snippet.description)}"/>`,
|
|
`<link rel="canonical" href="${escapeHtmlAttr(snippet.url)}"/>`,
|
|
`<meta property="og:type" content="${escapeHtmlAttr(snippet.ogType)}"/>`,
|
|
`<meta property="og:title" content="${escapeHtmlAttr(snippet.title)}"/>`,
|
|
`<meta property="og:description" content="${escapeHtmlAttr(snippet.description)}"/>`,
|
|
`<meta property="og:url" content="${escapeHtmlAttr(snippet.url)}"/>`,
|
|
`<meta name="twitter:card" content="summary"/>`,
|
|
`<meta name="twitter:title" content="${escapeHtmlAttr(snippet.title)}"/>`,
|
|
`<meta name="twitter:description" content="${escapeHtmlAttr(snippet.description)}"/>`,
|
|
`<script type="application/ld+json">${JSON.stringify(snippet.jsonLd)}</script>`
|
|
].join('\n\t\t');
|
|
|
|
// Replace the document title if present.
|
|
html = html.replace(/<title>[^<]*<\/title>/i, `<title>${escapeHtmlAttr(snippet.title)}</title>`);
|
|
|
|
// Inject tags before </head>.
|
|
if (/<\/head>/i.test(html)) {
|
|
return html.replace(/<\/head>/i, `\t\t${metaTags}\n\n\t</head>`);
|
|
}
|
|
return html + '\n' + metaTags;
|
|
}
|
|
|
|
/**
|
|
* Template handler middleware
|
|
* Preprocesses HTML templates and injects the CSP nonce
|
|
*/
|
|
function templateHandlerMiddleware(staticDir, options) {
|
|
const opts = options || {};
|
|
const store = opts.store;
|
|
const staticDocuments = opts.documents || {};
|
|
const config = opts.config || {};
|
|
|
|
// Load the index.html template at startup to avoid reading from disk on each request
|
|
try {
|
|
const indexPath = path.join(staticDir, 'index.html');
|
|
templateCache[indexPath] = fs.readFileSync(indexPath, 'utf8');
|
|
winston.debug('Loaded template: ' + indexPath);
|
|
} catch (err) {
|
|
winston.error('Failed to load index.html template at startup:', err);
|
|
}
|
|
|
|
return function(req, res, next) {
|
|
// Only process specific URLs
|
|
if (req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/)) {
|
|
const indexPath = path.join(staticDir, 'index.html');
|
|
const isPasteRoute = req.url !== '/';
|
|
|
|
// If template is not in cache, try to load it
|
|
if (!templateCache[indexPath]) {
|
|
try {
|
|
templateCache[indexPath] = fs.readFileSync(indexPath, 'utf8');
|
|
winston.debug('Loaded template on demand: ' + indexPath);
|
|
} catch (err) {
|
|
winston.error('Error reading index.html template:', err);
|
|
return next();
|
|
}
|
|
}
|
|
|
|
// Get the CSP nonce from the request (set by CSP middleware)
|
|
const nonce = req.cspNonce || '';
|
|
|
|
const renderAndSend = function(pasteContent) {
|
|
// If the response was already sent (or the client disconnected), do nothing.
|
|
if (res.headersSent || res.writableEnded) {
|
|
return;
|
|
}
|
|
|
|
// Process the template - replace all nonce placeholders
|
|
let html;
|
|
try {
|
|
// Replace {{cspNonce}} placeholders
|
|
html = templateCache[indexPath].replace(/\{\{cspNonce\}\}/g, nonce);
|
|
|
|
// If this is a paste route and we have content, inject rich snippet tags.
|
|
if (isPasteRoute && pasteContent) {
|
|
const key = req.url.slice(1);
|
|
const snippet = generateRichSnippet({ key, content: pasteContent, req, config });
|
|
html = injectRichSnippetIntoHtml(html, snippet);
|
|
}
|
|
|
|
// Also ensure any <script> tags without nonce attribute get one
|
|
// This is a backup in case some scripts don't have the placeholder
|
|
html = html.replace(/<script([^>]*)>/g, function(match, attributes) {
|
|
// If it already has a nonce attribute, leave it alone
|
|
if (attributes && attributes.includes('nonce=')) {
|
|
return match;
|
|
}
|
|
// Otherwise, add the nonce attribute
|
|
return `<script${attributes} nonce="${nonce}">`;
|
|
});
|
|
} catch (err) {
|
|
winston.error('Error processing template:', err);
|
|
return next();
|
|
}
|
|
|
|
// Set response headers for HTML content
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Content-Length', Buffer.byteLength(html));
|
|
|
|
// Send the response
|
|
res.statusCode = 200;
|
|
res.end(html);
|
|
|
|
// Don't proceed to other middleware
|
|
return;
|
|
};
|
|
|
|
// If not a paste route, render immediately.
|
|
if (!isPasteRoute) {
|
|
return renderAndSend(null);
|
|
}
|
|
|
|
// If we don't have a store, fall back to the default HTML (SPA still works).
|
|
if (!store || typeof store.get !== 'function') {
|
|
return renderAndSend(null);
|
|
}
|
|
|
|
const key = req.url.slice(1);
|
|
const skipExpire = !!staticDocuments[key];
|
|
store.get(
|
|
key,
|
|
function(ret) {
|
|
if (res.headersSent || res.writableEnded) {
|
|
return;
|
|
}
|
|
return renderAndSend(ret || null);
|
|
},
|
|
skipExpire
|
|
);
|
|
// IMPORTANT: stop connect() from continuing down the middleware chain
|
|
// while we asynchronously fetch paste content for rich snippet injection.
|
|
return;
|
|
}
|
|
|
|
// Continue to next middleware for non-index requests
|
|
next();
|
|
};
|
|
}
|
|
|
|
module.exports = templateHandlerMiddleware;
|