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:
parent
c461cf2639
commit
66137a135c
4
about.md
4
about.md
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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, '&')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 /
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue