193 lines
5.1 KiB
JavaScript
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;
|