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:
callum5892
2026-03-10 22:41:22 +00:00
parent 51c03e5f42
commit 933d81a77f
15 changed files with 2278 additions and 158 deletions

43
src/index.js Normal file
View File

@@ -0,0 +1,43 @@
require('dotenv').config();
const express = require('express');
const path = require('path');
const { ensureBucket } = require('./lib/minio');
const pastesRouter = require('./routes/pastes');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json({ limit: '110kb' }));
app.use(express.static(path.join(__dirname, 'public')));
app.use('/api/pastes', pastesRouter);
// Serve the view page for any paste URL
app.get('/p/:id', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'paste.html'));
});
// Short raw URL for plain-text retrieval.
app.get('/raw/:id', (req, res) => {
req.url = `/${req.params.id}/raw`;
pastesRouter.handle(req, res);
});
// 404 catch-all
app.use((req, res) => res.status(404).json({ error: 'Not found.' }));
// Global error handler
app.use((err, req, res, _next) => {
console.error(err);
res.status(500).json({ error: 'Internal server error.' });
});
async function start() {
await ensureBucket();
app.listen(PORT, () => console.log(`Minipaste running → http://localhost:${PORT}`));
}
start().catch(err => {
console.error('Failed to start:', err.message);
process.exit(1);
});

21
src/lib/minio.js Normal file
View File

@@ -0,0 +1,21 @@
const Minio = require('minio');
const client = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT || '9000', 10),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
});
const BUCKET = process.env.MINIO_BUCKET || 'pastes';
async function ensureBucket() {
const exists = await client.bucketExists(BUCKET);
if (!exists) {
await client.makeBucket(BUCKET);
console.log(`Created MinIO bucket: ${BUCKET}`);
}
}
module.exports = { client, BUCKET, ensureBucket };

129
src/public/app.js Normal file
View File

@@ -0,0 +1,129 @@
const contentEl = document.getElementById('content');
const lineNumbersEl = document.getElementById('line-numbers');
const charCountEl = document.getElementById('char-count');
const submitBtn = document.getElementById('submit-btn');
const errorBox = document.getElementById('error-box');
const langSelect = document.getElementById('lang-select');
const expirySelect = document.getElementById('expiry-select');
const editorHighlightEl = document.getElementById('editor-highlight');
const editorHighlightCodeEl = document.getElementById('editor-highlight-code');
const MAX_BYTES = 100 * 1024;
function byteLen(str) {
return new TextEncoder().encode(str).length;
}
function showError(msg) {
errorBox.textContent = msg;
errorBox.classList.remove('hidden');
}
function hideMessages() {
errorBox.classList.add('hidden');
}
function updateLineNumbers() {
const count = contentEl.value.split('\n').length;
lineNumbersEl.textContent = Array.from({ length: count }, (_, i) => i + 1).join('\n');
lineNumbersEl.scrollTop = contentEl.scrollTop;
}
function updateEditorHighlight() {
const content = contentEl.value;
const lang = langSelect.value;
editorHighlightCodeEl.textContent = content || ' ';
editorHighlightCodeEl.className = '';
delete editorHighlightCodeEl.dataset.highlighted;
if (!lang || typeof hljs === 'undefined') {
return;
}
if (!/^[a-zA-Z0-9+#.-]{1,32}$/.test(lang)) {
return;
}
editorHighlightCodeEl.classList.add(`language-${lang.toLowerCase()}`);
hljs.highlightElement(editorHighlightCodeEl);
}
// Keep gutter scroll in sync when the user scrolls the textarea
contentEl.addEventListener('scroll', () => {
lineNumbersEl.scrollTop = contentEl.scrollTop;
editorHighlightEl.scrollTop = contentEl.scrollTop;
editorHighlightEl.scrollLeft = contentEl.scrollLeft;
});
// Live byte counter + line numbers
contentEl.addEventListener('input', () => {
const bytes = byteLen(contentEl.value);
charCountEl.textContent = `${bytes.toLocaleString()} / 102,400 bytes`;
charCountEl.classList.toggle('over', bytes > MAX_BYTES);
updateLineNumbers();
updateEditorHighlight();
});
langSelect.addEventListener('change', updateEditorHighlight);
// Initialise line numbers on page load
updateLineNumbers();
updateEditorHighlight();
// Tab key inserts two spaces instead of moving focus
contentEl.addEventListener('keydown', e => {
if (e.key === 'Tab') {
e.preventDefault();
const s = contentEl.selectionStart;
const end = contentEl.selectionEnd;
contentEl.value =
contentEl.value.slice(0, s) + ' ' + contentEl.value.slice(end);
contentEl.selectionStart = contentEl.selectionEnd = s + 2;
}
});
submitBtn.addEventListener('click', async () => {
hideMessages();
const content = contentEl.value;
const lang = langSelect.value || null;
const expiresIn = expirySelect.value || 'never';
if (!content.trim()) {
showError('Please enter some content before creating a paste.');
return;
}
if (byteLen(content) > MAX_BYTES) {
showError('Content exceeds the 100 KB limit.');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Creating…';
try {
const res = await fetch('/api/pastes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, lang, expiresIn }),
});
const data = await res.json();
if (!res.ok) {
showError(data.error || 'Failed to create paste.');
return;
}
window.location.href = data.url;
} catch {
showError('Network error — is the server running?');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Create Paste';
}
});

72
src/public/index.html Normal file
View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minipaste — New Paste</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" />
</head>
<body>
<header>
<a href="/" class="logo">Minipaste</a>
</header>
<main>
<div class="editor-card">
<div class="toolbar">
<select id="lang-select" title="Language">
<option value="">Plain Text</option>
<option value="bash">Bash</option>
<option value="c">C</option>
<option value="cpp">C++</option>
<option value="css">CSS</option>
<option value="go">Go</option>
<option value="html">HTML</option>
<option value="java">Java</option>
<option value="javascript">JavaScript</option>
<option value="json">JSON</option>
<option value="python">Python</option>
<option value="rust">Rust</option>
<option value="sql">SQL</option>
<option value="toml">TOML</option>
<option value="typescript">TypeScript</option>
<option value="xml">XML</option>
<option value="yaml">YAML</option>
</select>
<select id="expiry-select" title="Expiry">
<option value="never">Never Expire</option>
<option value="1m">Expires in 1 Minute (test)</option>
<option value="1h">Expires in 1 Hour</option>
<option value="1d">Expires in 1 Day</option>
<option value="1w">Expires in 1 Week</option>
</select>
<span id="char-count" class="char-count">0 / 102,400 bytes</span>
<button id="submit-btn" type="button">Create Paste</button>
</div>
<div class="editor-body">
<div class="line-numbers" id="line-numbers" aria-hidden="true">1</div>
<div class="editor-stack">
<pre id="editor-highlight" aria-hidden="true"><code id="editor-highlight-code"></code></pre>
<textarea
id="content"
placeholder="Paste your text here..."
spellcheck="false"
autocomplete="off"
autofocus
></textarea>
</div>
</div>
</div>
<div id="error-box" class="error-box hidden"></div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script src="/app.js"></script>
</body>
</html>

38
src/public/paste.html Normal file
View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Minipaste</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/github-dark.min.css" />
</head>
<body>
<header>
<a href="/" class="logo">Minipaste</a>
<div class="header-actions">
<a href="/" class="btn">New Paste</a>
</div>
</header>
<main class="paste-view">
<div class="paste-meta">
<span id="paste-lang" class="lang-badge hidden"></span>
<span id="paste-date" class="paste-date"></span>
<span id="paste-expiry" class="paste-date"></span>
<div class="paste-meta-actions">
<button id="download-btn" type="button">Download</button>
<button id="copy-btn" type="button">Copy</button>
</div>
</div>
<div id="paste-content">
<div class="line-numbers" id="paste-line-numbers" aria-hidden="true">1</div>
<pre><code id="paste-code">Loading…</code></pre>
</div>
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js"></script>
<script src="/paste.js"></script>
</body>
</html>

148
src/public/paste.js Normal file
View File

@@ -0,0 +1,148 @@
const pasteCodeEl = document.getElementById('paste-code');
const pasteLineNumEl = document.getElementById('paste-line-numbers');
const pasteDateEl = document.getElementById('paste-date');
const pasteExpiryEl = document.getElementById('paste-expiry');
const pasteLangEl = document.getElementById('paste-lang');
const copyBtn = document.getElementById('copy-btn');
const downloadBtn = document.getElementById('download-btn');
const LANG_EXT = {
bash: 'sh', c: 'c', cpp: 'cpp', css: 'css', go: 'go',
html: 'html', java: 'java', javascript: 'js', json: 'json',
python: 'py', rust: 'rs', sql: 'sql', toml: 'toml',
typescript: 'ts', xml: 'xml', yaml: 'yml',
};
function extForLang(lang) {
return (lang && LANG_EXT[lang.toLowerCase()]) || 'txt';
}
function setLineNumbers(content) {
const count = content.split('\n').length;
pasteLineNumEl.textContent = Array.from({ length: count }, (_, i) => i + 1).join('\n');
}
function applyHighlight(lang) {
if (!lang || typeof hljs === 'undefined') {
return;
}
if (!/^[a-zA-Z0-9+#.-]{1,32}$/.test(lang)) {
return;
}
const safeLang = lang.toLowerCase();
delete pasteCodeEl.dataset.highlighted;
pasteCodeEl.classList.add(`language-${safeLang}`);
hljs.highlightElement(pasteCodeEl);
}
// Extract the paste ID from the URL path /p/<id>
const id = window.location.pathname.replace(/^\/p\//, '');
function showNotFound() {
const main = document.querySelector('main');
main.innerHTML = '';
const div = document.createElement('div');
div.className = 'not-found';
const h2 = document.createElement('h2');
h2.textContent = '404';
const p = document.createElement('p');
p.textContent = 'Paste not found or has expired.';
const a = document.createElement('a');
a.href = '/';
a.className = 'btn';
a.textContent = 'New Paste';
div.append(h2, p, a);
main.appendChild(div);
}
function showError(msg) {
const main = document.querySelector('main');
main.innerHTML = '';
const div = document.createElement('div');
div.className = 'not-found';
const h2 = document.createElement('h2');
h2.textContent = 'Error';
const p = document.createElement('p');
p.textContent = msg; // textContent — never innerHTML
const a = document.createElement('a');
a.href = '/';
a.className = 'btn';
a.textContent = 'New Paste';
div.append(h2, p, a);
main.appendChild(div);
}
async function loadPaste() {
try {
const res = await fetch(`/api/pastes/${encodeURIComponent(id)}`);
if (res.status === 404) {
showNotFound();
return;
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
showError(data.error || 'Failed to load paste.');
return;
}
const data = await res.json();
// Use textContent — never innerHTML — to prevent XSS
pasteCodeEl.textContent = data.content;
setLineNumbers(data.content);
document.title = `Minipaste — ${id}`;
if (data.lang) {
pasteLangEl.textContent = data.lang;
pasteLangEl.classList.remove('hidden');
applyHighlight(data.lang);
}
if (data.createdAt) {
pasteDateEl.textContent = `Created: ${new Date(data.createdAt).toLocaleString()}`;
}
if (data.expiresAt) {
pasteExpiryEl.textContent = `Expires: ${new Date(data.expiresAt).toLocaleString()}`;
} else {
pasteExpiryEl.textContent = 'Expires: never';
}
} catch {
showError('Network error — is the server running?');
}
}
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(pasteCodeEl.textContent).then(() => {
copyBtn.textContent = 'Copied!';
setTimeout(() => { copyBtn.textContent = 'Copy'; }, 2000);
});
});
downloadBtn.addEventListener('click', () => {
const lang = pasteLangEl.classList.contains('hidden') ? null : pasteLangEl.textContent.trim();
const ext = extForLang(lang);
const blob = new Blob([pasteCodeEl.textContent], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${id}.${ext}`;
a.click();
URL.revokeObjectURL(url);
});
loadPaste();

346
src/public/style.css Normal file
View File

@@ -0,0 +1,346 @@
/* ─── Custom Properties ─── */
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface2: #23263a;
--accent: #7c3aed;
--accent-hover: #6d28d9;
--text: #e2e8f0;
--text-muted: #8892a4;
--border: #2a2d3e;
--error: #f87171;
--radius: 6px;
--mono: 'Fira Code', 'Cascadia Code', 'Consolas', 'Monaco', monospace;
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
line-height: 1.5;
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ─── Header ─── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
height: 54px;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.logo {
font-size: 1.15rem;
font-weight: 700;
color: var(--accent);
text-decoration: none;
letter-spacing: -0.01em;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* ─── Main ─── */
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 1.5rem;
max-width: 1040px;
width: 100%;
margin: 0 auto;
}
/* ─── Buttons ─── */
.btn,
button {
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent);
color: #fff;
border: none;
border-radius: var(--radius);
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, opacity 0.15s;
white-space: nowrap;
line-height: 1.4;
font-family: var(--sans);
}
.btn:hover,
button:hover { background: var(--accent-hover); }
button:disabled { opacity: 0.55; cursor: not-allowed; }
/* ─── Editor Card ─── */
.editor-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--surface2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
select {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.3rem 0.55rem;
font-size: 0.85rem;
font-family: var(--sans);
cursor: pointer;
outline: none;
}
select:focus { border-color: var(--accent); }
.char-count {
font-size: 0.78rem;
color: var(--text-muted);
margin-left: auto;
margin-right: 0.25rem;
font-variant-numeric: tabular-nums;
}
.char-count.over { color: var(--error); }
/* ─── Editor body (line numbers + textarea side by side) ─── */
.editor-body {
display: flex;
flex: 1;
overflow: hidden;
min-height: 440px;
}
.editor-stack {
position: relative;
flex: 1;
overflow: hidden;
}
.line-numbers {
padding: 1rem 0.65rem 1rem 0.75rem;
font-family: var(--mono);
font-size: 0.9rem;
line-height: 1.65;
color: var(--text-muted);
text-align: right;
white-space: pre;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
flex-shrink: 0;
min-width: 2.8rem;
background: var(--surface2);
border-right: 1px solid var(--border);
}
textarea#content {
position: absolute;
inset: 0;
background: transparent;
color: transparent;
caret-color: var(--text);
border: none;
outline: none;
padding: 1rem;
font-family: var(--mono);
font-size: 0.9rem;
line-height: 1.65;
resize: none;
tab-size: 2;
overflow: auto;
z-index: 2;
}
textarea#content::placeholder { color: var(--text-muted); }
#editor-highlight {
position: absolute;
inset: 0;
margin: 0;
padding: 1rem;
overflow: hidden;
pointer-events: none;
z-index: 1;
}
#editor-highlight-code {
font-family: var(--mono);
font-size: 0.9rem;
line-height: 1.65;
white-space: pre;
display: block;
min-height: 100%;
}
#editor-highlight-code.hljs {
background: transparent;
padding: 0;
}
/* ─── Result / Error boxes ─── */
.result-box {
margin-top: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.85rem 1.1rem;
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
}
.result-label {
font-size: 0.8rem;
color: var(--text-muted);
flex-shrink: 0;
}
.result-box a {
color: var(--accent);
font-family: var(--mono);
font-size: 0.875rem;
word-break: break-all;
flex: 1;
min-width: 0;
}
.error-box {
margin-top: 0.9rem;
color: var(--error);
background: rgba(248, 113, 113, 0.08);
border: 1px solid rgba(248, 113, 113, 0.25);
border-radius: var(--radius);
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.hidden { display: none !important; }
/* ─── Paste View ─── */
.paste-view { padding-top: 1.25rem; }
.paste-meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
min-height: 1.5rem;
}
.paste-meta #copy-btn {
margin-left: auto;
}
.paste-meta-actions {
margin-left: auto;
display: flex;
gap: 0.5rem;
}
.lang-badge {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15rem 0.5rem;
font-size: 0.75rem;
font-family: var(--mono);
color: var(--text-muted);
text-transform: lowercase;
}
.paste-date {
font-size: 0.78rem;
color: var(--text-muted);
}
#paste-content {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: auto;
flex: 1;
display: flex;
}
#paste-content pre {
flex: 1;
margin: 0;
padding: 1rem;
overflow: visible;
}
#paste-content code {
font-family: var(--mono);
font-size: 0.9rem;
line-height: 1.65;
white-space: pre;
color: var(--text);
display: block;
}
#paste-content code.hljs {
background: transparent;
padding: 0;
}
/* ─── Not Found / Error state ─── */
.not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 4rem 2rem;
text-align: center;
gap: 0.75rem;
}
.not-found h2 {
font-size: 4rem;
font-weight: 800;
color: var(--border);
line-height: 1;
}
.not-found p {
color: var(--text-muted);
margin-bottom: 0.5rem;
}
/* ─── Responsive ─── */
@media (max-width: 600px) {
main, header { padding-left: 1rem; padding-right: 1rem; }
.toolbar { flex-wrap: wrap; }
.char-count { order: 3; width: 100%; margin: 0; }
}

167
src/routes/pastes.js Normal file
View 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;