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

View File

@@ -0,0 +1,12 @@
{
"name": "Minipaste Dev Container",
"image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm",
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "App",
"onAutoForward": "notify"
}
},
"postCreateCommand": "npm install"
}

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
PORT=3000
# MinIO connection
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=pastes

276
.gitignore vendored
View File

@@ -1,138 +1,138 @@
# ---> Node # ---> Node
# Logs # Logs
logs logs
*.log *.log
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
lerna-debug.log* lerna-debug.log*
.pnpm-debug.log* .pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data # Runtime data
pids pids
*.pid *.pid
*.seed *.seed
*.pid.lock *.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Directory for instrumented libs generated by jscoverage/JSCover
lib-cov lib-cov
# Coverage directory used by tools like istanbul # Coverage directory used by tools like istanbul
coverage coverage
*.lcov *.lcov
# nyc test coverage # nyc test coverage
.nyc_output .nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt .grunt
# Bower dependency directory (https://bower.io/) # Bower dependency directory (https://bower.io/)
bower_components bower_components
# node-waf configuration # node-waf configuration
.lock-wscript .lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html) # Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/) # Snowpack dependency directory (https://snowpack.dev/)
web_modules/ web_modules/
# TypeScript cache # TypeScript cache
*.tsbuildinfo *.tsbuildinfo
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache
# Optional stylelint cache # Optional stylelint cache
.stylelintcache .stylelintcache
# Microbundle cache # Microbundle cache
.rpt2_cache/ .rpt2_cache/
.rts2_cache_cjs/ .rts2_cache_cjs/
.rts2_cache_es/ .rts2_cache_es/
.rts2_cache_umd/ .rts2_cache_umd/
# Optional REPL history # Optional REPL history
.node_repl_history .node_repl_history
# Output of 'npm pack' # Output of 'npm pack'
*.tgz *.tgz
# Yarn Integrity file # Yarn Integrity file
.yarn-integrity .yarn-integrity
# dotenv environment variable files # dotenv environment variable files
.env .env
.env.development.local .env.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
.env.local .env.local
# parcel-bundler cache (https://parceljs.org/) # parcel-bundler cache (https://parceljs.org/)
.cache .cache
.parcel-cache .parcel-cache
# Next.js build output # Next.js build output
.next .next
out out
# Nuxt.js build / generate output # Nuxt.js build / generate output
.nuxt .nuxt
dist dist
# Gatsby files # Gatsby files
.cache/ .cache/
# Comment in the public line in if your project uses Gatsby and not Next.js # Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support # https://nextjs.org/blog/next-9-1#public-directory-support
# public # public
# vuepress build output # vuepress build output
.vuepress/dist .vuepress/dist
# vuepress v2.x temp and cache directory # vuepress v2.x temp and cache directory
.temp .temp
.cache .cache
# vitepress build output # vitepress build output
**/.vitepress/dist **/.vitepress/dist
# vitepress cache directory # vitepress cache directory
**/.vitepress/cache **/.vitepress/cache
# Docusaurus cache and generated files # Docusaurus cache and generated files
.docusaurus .docusaurus
# Serverless directories # Serverless directories
.serverless/ .serverless/
# FuseBox cache # FuseBox cache
.fusebox/ .fusebox/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
# TernJS port file # TernJS port file
.tern-port .tern-port
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
# yarn v2 # yarn v2
.yarn/cache .yarn/cache
.yarn/unplugged .yarn/unplugged
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*

36
LICENSE
View File

@@ -1,18 +1,18 @@
MIT License MIT License
Copyright (c) 2026 callum Copyright (c) 2026 callum
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions: following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software. portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE. USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,2 +1,2 @@
# Minipaste # Minipaste

1117
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "minipaste",
"version": "1.0.0",
"description": "A minimal pastebin alternative backed by MinIO",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"dotenv": "^16.0.0",
"express": "^4.21.0",
"minio": "^8.0.0"
}
}

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;