Files
binit/src/routes/pastes.js
2026-03-17 02:07:55 +00:00

193 lines
5.1 KiB
JavaScript

const express = require('express');
const crypto = require('crypto');
const { client, BUCKET } = require('../lib/minio');
const router = express.Router();
const MAX_BYTES = 100 * 1024; // 100 KB
const ID_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const ID_RE = /^[A-Za-z0-9]{8}$/;
const EXPIRY_MS = {
never: null,
'1m': 60 * 1000,
'1h': 60 * 60 * 1000,
'1d': 24 * 60 * 60 * 1000,
'1w': 7 * 24 * 60 * 60 * 1000,
};
// Wrap async route handlers so thrown errors reach Express's error handler
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);
function generateId() {
const bytes = crypto.randomBytes(8);
return Array.from(bytes, b => ID_CHARS[b % ID_CHARS.length]).join('');
}
function streamToBuffer(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
}
function parseExpiryOption(expiresIn) {
if (typeof expiresIn !== 'string' || !(expiresIn in EXPIRY_MS)) {
return null;
}
const ms = EXPIRY_MS[expiresIn];
if (ms === null) {
return null;
}
return new Date(Date.now() + ms).toISOString();
}
function normalizeClientIp(ip) {
if (typeof ip !== 'string' || ip.length === 0) {
return 'unknown';
}
let normalized = ip.trim();
if (normalized.startsWith('::ffff:')) {
normalized = normalized.slice(7);
}
return normalized.replace(/[^0-9a-fA-F:.]/g, '').slice(0, 64) || 'unknown';
}
async function setPasteTags(id, tags) {
if (typeof client.setObjectTagging === 'function') {
await client.setObjectTagging(BUCKET, id, tags);
return;
}
if (typeof client.putObjectTagging === 'function') {
await client.putObjectTagging(BUCKET, id, tags);
}
}
function getExpiryFromMeta(metaData) {
return (
metaData['x-amz-meta-expires-at'] ||
metaData['expires-at'] ||
metaData['expiresat'] ||
null
);
}
function isExpired(expiresAt) {
if (!expiresAt) {
return false;
}
const ts = Date.parse(expiresAt);
return Number.isFinite(ts) && ts <= Date.now();
}
async function loadPasteOrNull(id) {
const stat = await client.statObject(BUCKET, id);
const metaData = stat.metaData || {};
const expiresAt = getExpiryFromMeta(metaData);
if (isExpired(expiresAt)) {
// Best-effort cleanup when an expired paste is requested.
client.removeObject(BUCKET, id).catch(() => {});
return null;
}
const stream = await client.getObject(BUCKET, id);
const content = (await streamToBuffer(stream)).toString('utf8');
const lang = metaData['x-amz-meta-lang'] || metaData['lang'] || null;
return {
id,
content,
lang,
expiresAt,
createdAt: stat.lastModified ? stat.lastModified.toISOString() : null,
};
}
// POST /api/pastes
router.post('/', wrap(async (req, res) => {
const { content, lang, expiresIn = 'never' } = req.body;
if (typeof content !== 'string' || content.trim().length === 0) {
return res.status(400).json({ error: 'Content is required.' });
}
if (Buffer.byteLength(content, 'utf8') > MAX_BYTES) {
return res.status(413).json({ error: 'Content exceeds the 100 KB limit.' });
}
const id = generateId();
const buf = Buffer.from(content, 'utf8');
const clientIp = normalizeClientIp(req.ip);
const meta = { 'Content-Type': 'text/plain; charset=utf-8' };
if (typeof lang === 'string' && lang.length > 0) {
// Strip anything that isn't alphanumeric / punctuation safe for a header value
meta['x-amz-meta-lang'] = lang.replace(/[^a-zA-Z0-9+#.-]/g, '').slice(0, 32);
}
if (!(expiresIn in EXPIRY_MS)) {
return res.status(400).json({ error: 'Invalid expiry option.' });
}
const expiresAt = parseExpiryOption(expiresIn);
if (expiresAt) {
meta['x-amz-meta-expires-at'] = expiresAt;
}
await client.putObject(BUCKET, id, buf, buf.length, meta);
await setPasteTags(id, { client_ip: clientIp });
res.status(201).json({ id, url: `/p/${id}`, expiresAt });
}));
// GET /api/pastes/:id
router.get('/:id', wrap(async (req, res) => {
const { id } = req.params;
if (!ID_RE.test(id)) {
return res.status(400).json({ error: 'Invalid paste ID.' });
}
try {
const paste = await loadPasteOrNull(id);
if (!paste) {
return res.status(404).json({ error: 'Paste not found.' });
}
res.json(paste);
} catch (err) {
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
return res.status(404).json({ error: 'Paste not found.' });
}
throw err;
}
}));
// GET /api/pastes/:id/raw
router.get('/:id/raw', wrap(async (req, res) => {
const { id } = req.params;
if (!ID_RE.test(id)) {
return res.status(400).type('text/plain').send('Invalid paste ID.');
}
try {
const paste = await loadPasteOrNull(id);
if (!paste) {
return res.status(404).type('text/plain').send('Paste not found.');
}
res.type('text/plain; charset=utf-8').send(paste.content);
} catch (err) {
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
return res.status(404).type('text/plain').send('Paste not found.');
}
throw err;
}
}));
module.exports = router;