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;