feat: initial Minipaste
- MinIO-backed paste storage with 8-char base62 IDs - Paste expiry (1m, 1h, 1d, 1w, never) stored in object metadata - Raw plain-text endpoint at /raw/:id - Syntax highlighting in editor and paste view via highlight.js - In-editor highlight overlay with live language switching - Download button with correct file extension per language - Auto-redirect to paste after creation
This commit is contained in:
167
src/routes/pastes.js
Normal file
167
src/routes/pastes.js
Normal file
@@ -0,0 +1,167 @@
|
||||
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 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 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);
|
||||
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;
|
||||
Reference in New Issue
Block a user