Add About navigation with UTM tracking

Add an About button/link with UTM params and include a Colin Knapp linkback on the about page. Fix duplicate/edit wiring and make the template handler safe for async paste metadata rendering.
This commit is contained in:
Colin 2026-01-21 14:55:45 -05:00
parent c461cf2639
commit 66137a135c
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
8 changed files with 297 additions and 38 deletions

View File

@ -12,6 +12,10 @@ It's important to understand that this service is purely plain text-based. Data
The source code for this Hastebin instance is available at our Git repository: [Nixius/hastebin](https://git.nixc.us/Nixius/hastebin.git). Feel free to explore, contribute, or use it to set up your own instance. The source code for this Hastebin instance is available at our Git repository: [Nixius/hastebin](https://git.nixc.us/Nixius/hastebin.git). Feel free to explore, contribute, or use it to set up your own instance.
## Linkback
Haste was originally created by [Colin Knapp](https://colinknapp.com/?utm_source=haste.nixc.us&utm_medium=about&utm_campaign=linkback).
## Static Pages ## Static Pages
Hastebin supports static documents that are permanently stored and never expire. These documents are loaded at server startup and can be accessed like regular pastes. Currently, the following static documents are available: Hastebin supports static documents that are permanently stored and never expire. These documents are loaded at server startup and can be accessed like regular pastes. Currently, the following static documents are available:

160
lib/rich_snippet.js Normal file
View File

@ -0,0 +1,160 @@
/**
* Rich snippet generation for paste content.
* Produces Open Graph / Twitter meta + Schema.org JSON-LD payload.
*/
/* eslint-disable no-control-regex */
function clamp(str, max) {
if (!str) return '';
if (str.length <= max) return str;
return str.slice(0, Math.max(0, max - 1)).trimEnd() + '…';
}
function escapeHtmlAttr(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function normalizeForSummary(text) {
// Remove ASCII control chars except \n and \t, normalize line endings.
const cleaned = String(text || '')
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/g, '');
return cleaned;
}
function firstNonEmptyLine(text) {
const lines = normalizeForSummary(text).split('\n');
for (const line of lines) {
const t = line.trim();
if (t) return t;
}
return '';
}
function looksLikeUrlList(text) {
const lines = normalizeForSummary(text).split('\n').map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return false;
const sample = lines.slice(0, 10);
const urlish = sample.filter((l) => /^https?:\/\/\S+$/i.test(l));
return urlish.length >= Math.max(2, Math.ceil(sample.length * 0.6));
}
function tryParseJson(text) {
const t = String(text || '').trim();
if (!(t.startsWith('{') || t.startsWith('['))) return null;
try {
return JSON.parse(t);
} catch (_) {
return null;
}
}
function guessKind(text) {
const t = normalizeForSummary(text);
const head = t.slice(0, 2000);
const first = firstNonEmptyLine(t);
if (/^#!/.test(first)) {
const m = first.match(/^#!\s*(\S+)/);
return m && m[1] ? `Script (${m[1]})` : 'Script';
}
const parsedJson = tryParseJson(t);
if (parsedJson) {
if (Array.isArray(parsedJson)) return 'JSON array';
if (parsedJson && typeof parsedJson === 'object') {
const keys = Object.keys(parsedJson).slice(0, 3);
if (keys.length) return `JSON object (${keys.join(', ')})`;
return 'JSON object';
}
return 'JSON';
}
if (/^Traceback \(most recent call last\):/m.test(head) || /\bException\b/.test(head)) {
return 'Error trace';
}
if (/\n\s*at\s+\S+\s+\(.*:\d+:\d+\)/.test(head) || /\bUnhandledPromiseRejection\b/.test(head)) {
return 'Error trace';
}
if (looksLikeUrlList(t)) return 'URL list';
if (/BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY/.test(head) || /AKIA[0-9A-Z]{16}/.test(head)) {
return 'Sensitive content';
}
if (/<html[\s>]/i.test(head) || /<!doctype html>/i.test(head)) return 'HTML snippet';
if (/^\s*\{[\s\S]*\}\s*$/.test(t) && /:\s*["{\[]/.test(head)) return 'Structured data';
if (/\b(select|insert|update|delete)\b[\s\S]*\bfrom\b/i.test(head)) return 'SQL snippet';
if (/\b(class|def)\s+\w+\b/.test(head) && /\bimport\b/.test(head)) return 'Code snippet';
if (/\b(function|const|let|var)\b/.test(head) || /\bimport\b/.test(head)) return 'Code snippet';
return 'Paste';
}
function buildSummary(text) {
const t = normalizeForSummary(text);
const lines = t.split('\n').map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return '';
// Prefer a few short-ish lines; avoid dumping massive single lines.
const picked = [];
for (const line of lines) {
const safe = line.replace(/\s+/g, ' ').trim();
if (!safe) continue;
picked.push(clamp(safe, 140));
if (picked.join(' ').length >= 220 || picked.length >= 3) break;
}
return clamp(picked.join(' · '), 220);
}
function computeBaseUrl(req, config) {
const protoHeader = req && req.headers ? req.headers['x-forwarded-proto'] : null;
const protocol =
(protoHeader && String(protoHeader).split(',')[0].trim()) ||
(req && req.connection && req.connection.encrypted ? 'https' : 'http');
const hostHeader = req && req.headers ? req.headers.host : null;
const host = hostHeader || (config && config.host && config.port ? `${config.host}:${config.port}` : '');
return `${protocol}://${host}`;
}
function generateRichSnippet(options) {
const key = options && options.key ? String(options.key) : '';
const content = options && options.content ? String(options.content) : '';
const req = options && options.req ? options.req : null;
const config = options && options.config ? options.config : null;
const kind = guessKind(content);
const summary = buildSummary(content) || `A ${kind.toLowerCase()} on hastebin.`;
const baseUrl = computeBaseUrl(req, config);
const url = key ? `${baseUrl}/${encodeURIComponent(key)}` : baseUrl;
const title = clamp(`${kind} · ${key}`, 70);
const description = clamp(summary, 200);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'CreativeWork',
name: title,
description: description,
url: url
};
return {
title,
description,
url,
ogType: 'article',
jsonLd,
escapeHtmlAttr
};
}
module.exports = {
generateRichSnippet,
escapeHtmlAttr
};

View File

@ -6,15 +6,47 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const winston = require('winston'); const winston = require('winston');
const { generateRichSnippet, escapeHtmlAttr } = require('./rich_snippet');
// Cache for HTML templates // Cache for HTML templates
const templateCache = {}; 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 * Template handler middleware
* Preprocesses HTML templates and injects the CSP nonce * Preprocesses HTML templates and injects the CSP nonce
*/ */
function templateHandlerMiddleware(staticDir) { 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 // Load the index.html template at startup to avoid reading from disk on each request
try { try {
const indexPath = path.join(staticDir, 'index.html'); const indexPath = path.join(staticDir, 'index.html');
@ -28,6 +60,7 @@ function templateHandlerMiddleware(staticDir) {
// Only process specific URLs // Only process specific URLs
if (req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/)) { if (req.url === '/' || req.url.match(/^\/[a-zA-Z0-9_-]+$/)) {
const indexPath = path.join(staticDir, 'index.html'); const indexPath = path.join(staticDir, 'index.html');
const isPasteRoute = req.url !== '/';
// If template is not in cache, try to load it // If template is not in cache, try to load it
if (!templateCache[indexPath]) { if (!templateCache[indexPath]) {
@ -42,40 +75,77 @@ function templateHandlerMiddleware(staticDir) {
// Get the CSP nonce from the request (set by CSP middleware) // Get the CSP nonce from the request (set by CSP middleware)
const nonce = req.cspNonce || ''; const nonce = req.cspNonce || '';
// Add debug log const renderAndSend = function(pasteContent) {
console.log('Template handler processing with nonce:', nonce); // If the response was already sent (or the client disconnected), do nothing.
if (res.headersSent || res.writableEnded) {
// Process the template - replace all nonce placeholders return;
let html; }
try {
// Replace {{cspNonce}} placeholders // Process the template - replace all nonce placeholders
html = templateCache[indexPath].replace(/\{\{cspNonce\}\}/g, nonce); let html;
try {
// Also ensure any <script> tags without nonce attribute get one // Replace {{cspNonce}} placeholders
// This is a backup in case some scripts don't have the placeholder html = templateCache[indexPath].replace(/\{\{cspNonce\}\}/g, nonce);
html = html.replace(/<script([^>]*)>/g, function(match, attributes) {
// If it already has a nonce attribute, leave it alone // If this is a paste route and we have content, inject rich snippet tags.
if (attributes && attributes.includes('nonce=')) { if (isPasteRoute && pasteContent) {
return match; const key = req.url.slice(1);
const snippet = generateRichSnippet({ key, content: pasteContent, req, config });
html = injectRichSnippetIntoHtml(html, snippet);
} }
// Otherwise, add the nonce attribute
return `<script${attributes} nonce="${nonce}">`; // Also ensure any <script> tags without nonce attribute get one
}); // This is a backup in case some scripts don't have the placeholder
} catch (err) { html = html.replace(/<script([^>]*)>/g, function(match, attributes) {
winston.error('Error processing template:', err); // If it already has a nonce attribute, leave it alone
return next(); 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);
} }
// Set response headers for HTML content // If we don't have a store, fall back to the default HTML (SPA still works).
res.setHeader('Content-Type', 'text/html; charset=utf-8'); if (!store || typeof store.get !== 'function') {
res.setHeader('Content-Length', Buffer.byteLength(html)); return renderAndSend(null);
}
// Send the response
res.statusCode = 200; const key = req.url.slice(1);
res.end(html); const skipExpire = !!staticDocuments[key];
store.get(
// Don't proceed to other middleware 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; return;
} }

View File

@ -220,7 +220,11 @@ app.use(connect_st({
})); }));
// Add the template handler for index.html - only handles the main URLs // Add the template handler for index.html - only handles the main URLs
app.use(templateHandler(require('path').join(__dirname, 'static'))); app.use(templateHandler(require('path').join(__dirname, 'static'), {
store: preferredStore,
documents: config.documents,
config: config
}));
// Then we can loop back - and everything else should be a token, // Then we can loop back - and everything else should be a token,
// so route it back to / // so route it back to /

View File

@ -208,6 +208,26 @@ textarea {
background-position: -115px bottom; background-position: -115px bottom;
} }
/* About navigation button (stable, no JS) */
#box2 .about-nav,
#box2 .about-nav:visited {
display: inline-flex;
align-items: center;
justify-content: center;
height: 37px;
padding: 0 10px;
margin-left: 0;
font-family: Helvetica, sans-serif;
font-size: 12px;
color: #c4dce3;
text-decoration: none;
white-space: nowrap;
}
#box2 .about-nav:hover {
color: #fff;
}
#messages { #messages {
position:fixed; position:fixed;
top:0px; top:0px;

View File

@ -270,7 +270,7 @@ haste.prototype.configureButtons = function() {
shortcutDescription: 'control + s', shortcutDescription: 'control + s',
action: function() { action: function() {
if (_this.doc.locked) { if (_this.doc.locked) {
_this.duplicate(); _this.duplicateDocument();
} }
else { else {
_this.lockDocument(); _this.lockDocument();
@ -296,7 +296,7 @@ haste.prototype.configureButtons = function() {
}, },
shortcutDescription: 'control + d', shortcutDescription: 'control + d',
action: function() { action: function() {
_this.duplicate(); _this.duplicateDocument();
} }
}, },
{ {

File diff suppressed because one or more lines are too long

View File

@ -49,13 +49,14 @@
<div id="key"> <div id="key">
<div id="pointer" style="display:none;"></div> <div id="pointer" style="display:none;"></div>
<div id="box1"> <div id="box1">
<a href="/about.md" class="logo"></a> <a href="/about?utm_source=haste.nixc.us&utm_medium=logo&utm_campaign=about" class="logo"></a>
</div> </div>
<div id="box2"> <div id="box2">
<button class="save function button-picture">Save</button> <button class="save function button-picture">Save</button>
<button class="new function button-picture">New</button> <button class="new function button-picture">New</button>
<button class="duplicate function button-picture">Duplicate & Edit</button> <button class="duplicate function button-picture">Duplicate & Edit</button>
<button class="raw function button-picture">Just Text</button> <button class="raw function button-picture">Just Text</button>
<a class="about-nav" href="/about?utm_source=haste.nixc.us&utm_medium=button&utm_campaign=about">About</a>
</div> </div>
<div id="box3" style="display:none;"> <div id="box3" style="display:none;">
<div class="label"></div> <div class="label"></div>