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:
43
src/index.js
Normal file
43
src/index.js
Normal 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
21
src/lib/minio.js
Normal 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
129
src/public/app.js
Normal 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
72
src/public/index.html
Normal 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
38
src/public/paste.html
Normal 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
148
src/public/paste.js
Normal 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
346
src/public/style.css
Normal 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
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