diff --git a/about.md b/about.md index 400b38d..d9ec338 100644 --- a/about.md +++ b/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. +## Linkback + +Haste was originally created by [Colin Knapp](https://colinknapp.com/?utm_source=haste.nixc.us&utm_medium=about&utm_campaign=linkback). + ## 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: diff --git a/lib/rich_snippet.js b/lib/rich_snippet.js new file mode 100644 index 0000000..28d7d90 --- /dev/null +++ b/lib/rich_snippet.js @@ -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, '>'); +} + +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 (/]/i.test(head) || //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 +}; + diff --git a/lib/template_handler.js b/lib/template_handler.js index a19e117..988ba95 100644 --- a/lib/template_handler.js +++ b/lib/template_handler.js @@ -6,15 +6,47 @@ 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 = [ + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + `` + ].join('\n\t\t'); + + // Replace the document title if present. + html = html.replace(/