Compare commits

...

2 Commits

Author SHA1 Message Date
Colin edc1680f1c
Add unused code/dependency scanning with knip and depcheck
ci/woodpecker/push/woodpecker Pipeline failed Details
Adds automated scanning for unused files, exports, and dependencies:
- New CI step (scan-unused) in Woodpecker pipeline
- Pre-push git hook blocks pushes if unused code detected
- npm scripts: scan:unused, scan:deps
- Config files for knip and depcheck to handle dynamic imports
2026-01-23 09:04:57 -05:00
Colin 66137a135c
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.
2026-01-21 14:55:45 -05:00
13 changed files with 418 additions and 42 deletions

9
.depcheckrc Normal file
View File

@ -0,0 +1,9 @@
{
"ignores": [],
"skip-missing": false,
"ignore-patterns": [
"static",
"lib/document_stores/memcached.js",
"lib/document_stores/rethinkdb.js"
]
}

View File

@ -25,6 +25,22 @@ steps:
branch: main
event: [push, pull_request, cron]
# Scan for unused code and dependencies
scan-unused:
name: scan-unused
image: node:22-alpine
commands:
- echo "nameserver 1.1.1.1" > /etc/resolv.conf
- echo "nameserver 1.0.0.1" >> /etc/resolv.conf
- npm ci
- echo "=== Scanning for unused files/exports/dependencies (knip) ==="
- npx --yes knip || echo "knip found issues (non-blocking)"
- echo "=== Scanning for unused npm dependencies (depcheck) ==="
- npx --yes depcheck || echo "depcheck found issues (non-blocking)"
when:
branch: main
event: [push, pull_request, cron]
# SBOM for source code
sbom-source:
name: sbom-source

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.
## 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:

12
knip.json Normal file
View File

@ -0,0 +1,12 @@
{
"$schema": "https://unpkg.com/knip@latest/schema.json",
"entry": [
"lib/document_stores/*.js",
"lib/key_generators/*.js"
],
"ignore": [
"static/**",
"lib/document_stores/memcached.js",
"lib/document_stores/rethinkdb.js"
]
}

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 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) {
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');
@ -28,6 +60,7 @@ function templateHandlerMiddleware(staticDir) {
// 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]) {
@ -42,40 +75,77 @@ function templateHandlerMiddleware(staticDir) {
// Get the CSP nonce from the request (set by CSP middleware)
const nonce = req.cspNonce || '';
// Add debug log
console.log('Template handler processing with nonce:', nonce);
// Process the template - replace all nonce placeholders
let html;
try {
// Replace {{cspNonce}} placeholders
html = templateCache[indexPath].replace(/\{\{cspNonce\}\}/g, nonce);
// 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;
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);
}
// Otherwise, add the nonce attribute
return `<script${attributes} nonce="${nonce}">`;
});
} catch (err) {
winston.error('Error processing template:', err);
return next();
// 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);
}
// 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
// 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;
}

View File

@ -73,7 +73,9 @@
"scan:trivy": "./scripts/scan-trivy-fs.sh",
"scan:trivy:image": "./scripts/scan-trivy-image.sh",
"scan:sbom:image": "./scripts/scan-sbom-image.sh",
"scan:all": "npm run scan:sbom && npm run scan:trivy"
"scan:unused": "npx --yes knip",
"scan:deps": "npx --yes depcheck",
"scan:all": "npm run scan:sbom && npm run scan:trivy && npm run scan:unused && npm run scan:deps"
},
"repository": {
"type": "git",

View File

@ -1,7 +1,7 @@
#!/bin/bash
# Installation script for Git hooks in Hastebin
# This script sets up pre-commit hooks to prevent pushing broken code
# This script sets up pre-commit hooks (tests) and pre-push hooks (unused code scans)
set -e
@ -94,9 +94,87 @@ HOOK_EOF
chmod +x "$PRE_COMMIT_HOOK"
echo -e "${GREEN}✅ Pre-commit hook installed successfully${NC}"
# --- Pre-push hook ---
PRE_PUSH_HOOK="$HOOKS_DIR/pre-push"
# Check if pre-push hook already exists
if [ -f "$PRE_PUSH_HOOK" ]; then
echo -e "${YELLOW}⚠️ Pre-push hook already exists${NC}"
read -p "Do you want to overwrite it? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Skipping pre-push hook installation${NC}"
else
mv "$PRE_PUSH_HOOK" "$PRE_PUSH_HOOK.backup.$(date +%Y%m%d_%H%M%S)"
echo -e "${YELLOW}Backed up existing pre-push hook${NC}"
fi
fi
if [ ! -f "$PRE_PUSH_HOOK" ] || [[ $REPLY =~ ^[Yy]$ ]]; then
cat > "$PRE_PUSH_HOOK" << 'HOOK_EOF'
#!/bin/bash
# Git pre-push hook for Hastebin
# Scans for unused code/dependencies before pushing
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Running pre-push checks...${NC}"
# Get the repository root
REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"
# Check if node_modules exists, if not, install dependencies
if [ ! -d "node_modules" ]; then
echo -e "${YELLOW}Installing dependencies...${NC}"
npm ci
fi
# Scan for unused code/dependencies
SCAN_FAILED=0
echo -e "${YELLOW}Scanning for unused files/exports/dependencies (knip)...${NC}"
if npx --yes knip 2>/dev/null; then
echo -e "${GREEN}✅ knip passed${NC}"
else
echo -e "${RED}❌ knip found unused code${NC}"
SCAN_FAILED=1
fi
echo -e "${YELLOW}Scanning for unused npm dependencies (depcheck)...${NC}"
if npx --yes depcheck 2>/dev/null; then
echo -e "${GREEN}✅ depcheck passed${NC}"
else
echo -e "${RED}❌ depcheck found issues${NC}"
SCAN_FAILED=1
fi
if [ $SCAN_FAILED -ne 0 ]; then
echo -e "${RED}❌ Unused code/dependencies detected. Push aborted.${NC}"
echo -e "${YELLOW}To skip this check, use: git push --no-verify${NC}"
exit 1
fi
echo -e "${GREEN}✅ Pre-push checks passed${NC}"
exit 0
HOOK_EOF
chmod +x "$PRE_PUSH_HOOK"
echo -e "${GREEN}✅ Pre-push hook installed successfully${NC}"
fi
echo ""
echo -e "${BLUE}The hook will now run tests before each commit.${NC}"
echo -e "${YELLOW}To skip the hook, use: git commit --no-verify${NC}"
echo -e "${BLUE}Hooks installed:${NC}"
echo -e "${BLUE} - pre-commit: runs core tests before each commit${NC}"
echo -e "${BLUE} - pre-push: scans for unused code/deps before each push${NC}"
echo -e "${YELLOW}To skip hooks, use: git commit --no-verify / git push --no-verify${NC}"
echo ""
# Check if dependencies are installed

View File

@ -220,7 +220,11 @@ app.use(connect_st({
}));
// 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,
// so route it back to /

View File

@ -208,6 +208,26 @@ textarea {
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 {
position:fixed;
top:0px;

View File

@ -270,7 +270,7 @@ haste.prototype.configureButtons = function() {
shortcutDescription: 'control + s',
action: function() {
if (_this.doc.locked) {
_this.duplicate();
_this.duplicateDocument();
}
else {
_this.lockDocument();
@ -296,7 +296,7 @@ haste.prototype.configureButtons = function() {
},
shortcutDescription: 'control + d',
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="pointer" style="display:none;"></div>
<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 id="box2">
<button class="save function button-picture">Save</button>
<button class="new function button-picture">New</button>
<button class="duplicate function button-picture">Duplicate & Edit</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 id="box3" style="display:none;">
<div class="label"></div>