E2EE for peer.paste

This commit is contained in:
Raven Scott
2026-05-27 22:55:37 -04:00
parent 7b4e1a2391
commit e901dea9ee
4 changed files with 468 additions and 59 deletions
+266 -30
View File
@@ -6,10 +6,11 @@ const DEFAULT_EXPIRES_HOURS = 24;
const MAX_EXPIRES_HOURS = 168;
const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const MAX_CONTENT_CHARS = 100000;
const MAX_TITLE_CHARS = 120;
const MAX_LANGUAGE_CHARS = 32;
const MAX_MAXREADS = 1000000;
const MAX_LIST_SCAN = 500;
const DEFAULT_PAGE_SIZE = 25;
const ENC_PREFIX = 'ENCv1:';
let cleanupTimer = null;
@@ -31,19 +32,44 @@ function sanitizeText(v, fallback = '') {
return typeof v === 'string' ? v : fallback;
}
function encodeEncryptedContent(payload) {
const safe = {
v: 1,
alg: 'A256GCM',
iv: sanitizeText(payload.iv),
ciphertext: sanitizeText(payload.ciphertext)
};
if (!safe.iv || !safe.ciphertext) {
throw new Error('encrypted payload must include iv and ciphertext');
}
return `${ENC_PREFIX}${JSON.stringify(safe)}`;
}
function decodeEncryptedContent(content) {
if (typeof content !== 'string') return null;
if (!content.startsWith(ENC_PREFIX)) return null;
try {
const parsed = JSON.parse(content.slice(ENC_PREFIX.length));
if (!parsed || !parsed.iv || !parsed.ciphertext) return null;
return parsed;
} catch {
return null;
}
}
function normalizePasteInput(body = {}, existing = null) {
const title = sanitizeText(body.title, existing ? existing.title : '').slice(0, MAX_TITLE_CHARS);
const language = sanitizeText(body.language, existing ? existing.language : '').slice(0, MAX_LANGUAGE_CHARS);
const content = body.content !== undefined ? sanitizeText(body.content) : (existing ? existing.content : '');
const encrypted = body.encrypted && typeof body.encrypted === 'object' ? body.encrypted : null;
const destroyOnRead = body.destroyOnRead !== undefined ? !!body.destroyOnRead : (existing ? !!existing.destroyOnRead : false);
const maxReadsRaw = body.maxReads !== undefined ? body.maxReads : (existing ? existing.maxReads : 0);
const maxReads = Math.max(0, Math.min(MAX_MAXREADS, Math.floor(Number(maxReadsRaw) || 0)));
const expiresHours = body.expiresHours !== undefined ? parseExpiresHours(body.expiresHours) : null;
return {
title,
language,
content,
encrypted,
destroyOnRead,
maxReads,
expiresHours
@@ -54,7 +80,6 @@ function toPublicPaste(paste, includeOwner = false) {
const t = now();
const out = {
id: paste.id,
title: paste.title || '',
language: paste.language || '',
destroyOnRead: !!paste.destroyOnRead,
maxReads: paste.maxReads || 0,
@@ -177,12 +202,10 @@ async function createPaste(req, res) {
if (!body) return true;
const input = normalizePasteInput(body);
if (!input.content || !input.content.trim()) {
return sdk.router.badRequest(res, 'content is required');
}
if (input.content.length > MAX_CONTENT_CHARS) {
return sdk.router.badRequest(res, `content exceeds ${MAX_CONTENT_CHARS} characters`);
if (!input.encrypted) {
return sdk.router.badRequest(res, 'encrypted payload is required');
}
const encryptedContent = encodeEncryptedContent(input.encrypted);
const createdAt = now();
const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000;
@@ -190,8 +213,8 @@ async function createPaste(req, res) {
const paste = {
id: generatePasteId(),
title: input.title,
content: input.content,
title: '',
content: encryptedContent,
language: input.language,
ownerPeerId,
destroyOnRead: input.destroyOnRead,
@@ -214,6 +237,8 @@ async function createPaste(req, res) {
}
async function listPastes(req, res, ownerOnly = false) {
const limit = Math.max(1, Math.min(100, Number(req.query?.limit) || DEFAULT_PAGE_SIZE));
const offset = Math.max(0, Number(req.query?.offset) || 0);
const { rows: all, timedOut } = await safeFindPastes({
includeExpired: false,
maxEntries: MAX_LIST_SCAN,
@@ -225,20 +250,25 @@ async function listPastes(req, res, ownerOnly = false) {
let items = all.filter((p) => p.expiresAt > t);
if (ownerOnly) items = items.filter((p) => (p.ownerPeerId || '') === ownerPeerId);
// Burn-after-first-read pastes should never appear in recent listings.
items = items.filter((p) => !p.destroyOnRead);
if (search) {
items = items.filter((p) =>
(p.title || '').toLowerCase().includes(search) ||
(p.language || '').toLowerCase().includes(search) ||
p.id.includes(search)
);
}
items.sort((a, b) => b.createdAt - a.createdAt);
const pastes = items.map((p) => toPublicPaste(p, ownerOnly));
const total = items.length;
const page = items.slice(offset, offset + limit);
const pastes = page.map((p) => toPublicPaste(p, ownerOnly));
return sdk.router.json(res, {
count: pastes.length,
pastes,
total,
hasMore: offset + limit < total,
degraded: timedOut
});
}
@@ -271,10 +301,13 @@ async function consumePaste(res, pasteId) {
}
await sdk.db.flush();
const encryptedPayload = decodeEncryptedContent(paste.content);
return sdk.router.json(res, {
paste: {
...toPublicPaste({ ...paste, readCount: nextReadCount }, true),
content: paste.content
encrypted: !!encryptedPayload,
encryptedPayload: encryptedPayload || undefined,
content: encryptedPayload ? undefined : paste.content
},
consumed
});
@@ -297,15 +330,18 @@ async function updatePaste(req, res, pasteId) {
if (body.content !== undefined && (!input.content || !input.content.trim())) {
return sdk.router.badRequest(res, 'content cannot be empty');
}
if (input.content.length > MAX_CONTENT_CHARS) {
return sdk.router.badRequest(res, `content exceeds ${MAX_CONTENT_CHARS} characters`);
let newContent = existing.content;
if (input.encrypted) {
newContent = encodeEncryptedContent(input.encrypted);
} else if (body.content !== undefined) {
return sdk.router.badRequest(res, 'plaintext content updates are not allowed');
}
const updated = {
...existing,
title: input.title,
title: '',
language: input.language,
content: body.content !== undefined ? input.content : existing.content,
content: newContent,
destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads,
expiresAt: input.expiresHours
@@ -366,7 +402,65 @@ async function getStats(res) {
}
function renderPastePage(paste) {
const title = paste.title || 'Untitled Paste';
const encryptedPayload = decodeEncryptedContent(paste.content);
if (encryptedPayload) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>peer.paste - ${paste.id}</title>
<style>
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
.card{background:#111827;border:1px solid #334155;border-radius:12px;padding:1rem}
a{color:#60a5fa}
.meta{font-size:.9rem;color:#94a3b8;margin:.5rem 0 1rem}
pre{white-space:pre-wrap;word-break:break-word;background:#020617;border:1px solid #1e293b;border-radius:8px;padding:1rem}
</style>
</head>
<body>
<div class="wrap">
<h1>paste ${paste.id}</h1>
<div class="meta">Encrypted paste. Key must be present in URL fragment.</div>
<div class="card"><pre id="out">Decrypting...</pre></div>
<p><a href="/">Create another paste</a></p>
</div>
<script>
const payload = ${JSON.stringify(encryptedPayload)};
function b64uToBytes(s) {
const p = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = p.length % 4 ? '='.repeat(4 - (p.length % 4)) : '';
const bin = atob(p + pad);
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
async function decryptAndRender() {
const out = document.getElementById('out');
const hash = location.hash || '';
const m = hash.match(/k=([^&]+)/);
if (!m) {
out.textContent = 'Missing decryption key in URL fragment (#k=...)';
return;
}
try {
const keyBytes = b64uToBytes(decodeURIComponent(m[1]));
const iv = b64uToBytes(payload.iv);
const ciphertext = b64uToBytes(payload.ciphertext);
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
out.textContent = new TextDecoder().decode(plain);
} catch (err) {
out.textContent = 'Failed to decrypt paste: ' + (err && err.message ? err.message : 'unknown error');
}
}
decryptAndRender();
</script>
</body>
</html>`;
}
const language = paste.language || 'plain';
const expiresIn = Math.max(0, paste.expiresAt - now());
const hours = Math.floor(expiresIn / (1000 * 60 * 60));
@@ -376,7 +470,7 @@ function renderPastePage(paste) {
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${title} - peer.paste</title>
<title>peer.paste - ${paste.id}</title>
<style>
body{margin:0;font-family:system-ui;background:#0f172a;color:#e2e8f0}
.wrap{max-width:960px;margin:2rem auto;padding:0 1rem}
@@ -388,7 +482,7 @@ function renderPastePage(paste) {
</head>
<body>
<div class="wrap">
<h1>${title}</h1>
<h1>paste ${paste.id}</h1>
<div class="meta">Language: ${language} | Expires in: ${hours}h ${minutes}m</div>
<div class="card"><pre>${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</pre></div>
<p><a href="/">Create another paste</a></p>
@@ -434,15 +528,14 @@ function apiDocsHtml(baseUrl) {
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/mine</h2>
<p>List active pastes owned by local peer.</p>
<p>List active pastes owned by local peer (burn-after-read hidden).</p>
</div>
<div class="card">
<h2><span class="m POST">POST</span>/api/pastes</h2>
<p>Create a paste.</p>
<pre>{
"title": "optional",
"language": "plain|js|json|md",
"content": "required string",
"encrypted": { "iv": "base64url", "ciphertext": "base64url" },
"expiresHours": 24,
"destroyOnRead": false,
"maxReads": 0
@@ -460,9 +553,8 @@ function apiDocsHtml(baseUrl) {
<h2><span class="m PUT">PUT</span>/api/pastes/:id</h2>
<p>Update a paste (owner only, auth required).</p>
<pre>{
"title": "optional",
"language": "optional",
"content": "optional",
"encrypted": { "iv": "base64url", "ciphertext": "base64url" },
"expiresHours": 12,
"destroyOnRead": true,
"maxReads": 10
@@ -600,9 +692,15 @@ function buildOpenApi(baseUrl) {
type: 'object',
required: ['content'],
properties: {
title: { type: 'string', maxLength: MAX_TITLE_CHARS },
language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS },
content: { type: 'string', maxLength: MAX_CONTENT_CHARS },
encrypted: {
type: 'object',
required: ['iv', 'ciphertext'],
properties: {
iv: { type: 'string' },
ciphertext: { type: 'string' }
}
},
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
@@ -611,9 +709,15 @@ function buildOpenApi(baseUrl) {
UpdatePasteRequest: {
type: 'object',
properties: {
title: { type: 'string', maxLength: MAX_TITLE_CHARS },
language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS },
content: { type: 'string', maxLength: MAX_CONTENT_CHARS },
encrypted: {
type: 'object',
required: ['iv', 'ciphertext'],
properties: {
iv: { type: 'string' },
ciphertext: { type: 'string' }
}
},
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
@@ -624,6 +728,132 @@ function buildOpenApi(baseUrl) {
};
}
async function getRecentPastesPage(ownerOnly = true, offset = 0, limit = DEFAULT_PAGE_SIZE) {
const { rows: all, timedOut } = await safeFindPastes({
includeExpired: false,
maxEntries: MAX_LIST_SCAN,
context: 'getRecentPastesPage'
});
const ownerPeerId = sdk.state.localPeerId || '';
let items = all.filter((p) => !p.destroyOnRead);
if (ownerOnly) items = items.filter((p) => (p.ownerPeerId || '') === ownerPeerId);
items.sort((a, b) => b.createdAt - a.createdAt);
const total = items.length;
const page = items.slice(offset, offset + limit).map((p) => toPublicPaste(p, true));
return {
pastes: page,
offset,
limit,
total,
hasMore: offset + limit < total,
degraded: timedOut
};
}
async function broadcastRecentPastesUpdate() {
try {
const page = await getRecentPastesPage(true, 0, DEFAULT_PAGE_SIZE);
sdk.websocket.broadcast({
type: 'pastes-updated',
data: page
});
} catch (err) {
sdk.log.warn('peer.paste', `Failed to broadcast paste update: ${err.message}`);
}
}
function setupWebSocketHandlers() {
if (!sdk.websocket.initialize()) {
sdk.log.warn('peer.paste', 'Failed to initialize WebSocket server');
return;
}
sdk.websocket.on('connection', async (ws) => {
try {
const initial = await getRecentPastesPage(true, 0, DEFAULT_PAGE_SIZE);
sdk.websocket.send(ws, { type: 'init', data: initial });
} catch (err) {
sdk.websocket.send(ws, { type: 'error', error: err.message });
}
});
sdk.websocket.on('message', async (ws, message) => {
try {
if (message.type === 'request-pastes') {
const offset = Math.max(0, Number(message.offset) || 0);
const limit = Math.max(1, Math.min(100, Number(message.limit) || DEFAULT_PAGE_SIZE));
const page = await getRecentPastesPage(true, offset, limit);
sdk.websocket.send(ws, { type: 'pastes-page', data: page, requestId: message.requestId || null });
return;
}
if (message.type === 'create-paste') {
const input = normalizePasteInput(message.payload || {});
if (!input.encrypted) {
sdk.websocket.send(ws, { type: 'error', error: 'encrypted payload is required', requestId: message.requestId || null });
return;
}
const encryptedContent = encodeEncryptedContent(input.encrypted);
const createdAt = now();
const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000;
const paste = {
id: generatePasteId(),
title: '',
content: encryptedContent,
language: input.language,
ownerPeerId: sdk.state.localPeerId || '',
destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads,
readCount: 0,
createdAt,
expiresAt
};
await sdk.db.insert(COLLECTION, paste);
await sdk.db.flush();
sdk.websocket.send(ws, {
type: 'paste-created',
requestId: message.requestId || null,
data: {
paste: toPublicPaste(paste, true),
viewUrl: `/p/${paste.id}`,
apiUrl: `/api/pastes/${paste.id}`,
rawUrl: `/api/pastes/${paste.id}/raw`
}
});
await broadcastRecentPastesUpdate();
return;
}
if (message.type === 'destroy-paste') {
const pasteId = sanitizeText(message.pasteId || '');
if (!pasteId) {
sdk.websocket.send(ws, { type: 'error', error: 'pasteId is required', requestId: message.requestId || null });
return;
}
const localPeer = sdk.state.localPeerId || '';
const paste = await sdk.db.get(COLLECTION, { id: pasteId });
if (!paste) {
sdk.websocket.send(ws, { type: 'error', error: 'Paste not found', requestId: message.requestId || null });
return;
}
if (paste.ownerPeerId && paste.ownerPeerId !== localPeer) {
sdk.websocket.send(ws, { type: 'error', error: 'Only the owner can destroy this paste', requestId: message.requestId || null });
return;
}
await deletePaste(paste);
await sdk.db.flush();
sdk.websocket.send(ws, { type: 'paste-destroyed', requestId: message.requestId || null, data: { id: pasteId } });
await broadcastRecentPastesUpdate();
}
} catch (err) {
sdk.websocket.send(ws, { type: 'error', error: err.message, requestId: message.requestId || null });
}
});
}
async function handler(req, res) {
try {
const parsed = sdk.router.parseRequest(req);
@@ -680,6 +910,10 @@ async function handler(req, res) {
const pasteId = path.slice('api/pastes/'.length);
return deletePasteRoute(req, res, pasteId);
}
if (path.startsWith('api/pastes/') && path.endsWith('/destroy') && method === 'POST') {
const pasteId = path.slice('api/pastes/'.length, -'/destroy'.length);
return deletePasteRoute(req, res, pasteId);
}
if (path.startsWith('p/') && method === 'GET') {
const pasteId = path.slice('p/'.length);
@@ -716,6 +950,7 @@ async function onInit() {
await cleanupExpiredPastes();
cleanupTimer = setInterval(cleanupExpiredPastes, CLEANUP_INTERVAL_MS);
setupWebSocketHandlers();
sdk.admin.registerSetting('defaultExpiryHours', {
type: 'number',
@@ -734,6 +969,7 @@ async function onShutdown() {
clearInterval(cleanupTimer);
cleanupTimer = null;
}
sdk.websocket.close();
sdk.log.info('peer.paste', 'peer.paste shutdown complete');
}
+2 -4
View File
@@ -21,9 +21,6 @@
<details class="advanced">
<summary>Advanced options</summary>
<div class="advanced-grid">
<label>Title (optional)
<input type="text" id="title" maxlength="120" />
</label>
<label>Language (optional)
<input type="text" id="language" maxlength="32" placeholder="plain / js / json / md" />
</label>
@@ -61,9 +58,10 @@
<section class="panel compact">
<div class="list-head">
<h2>Recent active pastes</h2>
<button id="refresh-btn" type="button" class="ghost">Refresh</button>
<span id="live-dot" class="live-dot">LIVE</span>
</div>
<div id="list"></div>
<div id="list-loading" class="list-loading hidden">Loading more...</div>
</section>
</main>
<script src="/main.js"></script>
+181 -25
View File
@@ -29,18 +29,61 @@ function setCreateState(message, isError = false) {
}
}
async function refreshList() {
const list = document.getElementById('list');
list.textContent = 'Loading...';
try {
const data = await api('/api/pastes/mine');
if (!data.pastes || data.pastes.length === 0) {
list.textContent = 'No active pastes yet.';
return;
function bytesToB64u(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function encryptContent(plaintext) {
const encoder = new TextEncoder();
const keyBytes = crypto.getRandomValues(new Uint8Array(32));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['encrypt']);
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(plaintext));
return {
key: bytesToB64u(keyBytes),
encrypted: {
iv: bytesToB64u(iv),
ciphertext: bytesToB64u(new Uint8Array(cipher))
}
list.innerHTML = data.pastes.map((p) => `
};
}
const PAGE_SIZE = 25;
let ws = null;
let requestCounter = 0;
const pending = new Map();
let allRecent = [];
let visibleCount = 0;
let hasMoreServer = true;
let isLoadingPage = false;
function nextRequestId() {
requestCounter += 1;
return `req-${Date.now()}-${requestCounter}`;
}
function wsUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws`;
}
function setListLoading(isLoading) {
const el = document.getElementById('list-loading');
el.classList.toggle('hidden', !isLoading);
}
function renderRecentList() {
const list = document.getElementById('list');
const rows = allRecent.slice(0, visibleCount);
if (rows.length === 0) {
list.textContent = 'No active pastes yet.';
return;
}
list.innerHTML = rows.map((p) => `
<div class="paste-item">
<div class="paste-title">${escapeHtml(p.title || '(untitled)')}</div>
<div class="paste-title">${escapeHtml(p.id)}</div>
<div class="paste-meta">
id: ${p.id} |
reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} |
@@ -50,11 +93,66 @@ async function refreshList() {
<a href="/p/${p.id}" target="_blank" rel="noopener">Open</a>
<a href="/api/pastes/${p.id}/raw" target="_blank" rel="noopener">Raw</a>
<a href="#" data-copy-url="/p/${p.id}">Copy link</a>
<a href="#" data-destroy-id="${p.id}" class="danger">Destroy</a>
</div>
</div>
`).join('');
}
function handleRealtimePage(data, reset = false) {
const incoming = Array.isArray(data.pastes) ? data.pastes : [];
if (reset) {
allRecent = incoming;
visibleCount = Math.min(PAGE_SIZE, allRecent.length);
} else {
allRecent = allRecent.concat(incoming);
visibleCount = Math.min(visibleCount + PAGE_SIZE, allRecent.length);
}
hasMoreServer = !!data.hasMore;
renderRecentList();
}
function sendWs(payload) {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
reject(new Error('Realtime connection is not open'));
return;
}
const requestId = nextRequestId();
const timeout = setTimeout(() => {
pending.delete(requestId);
reject(new Error('Realtime request timed out'));
}, 10000);
pending.set(requestId, {
resolve: (msg) => {
clearTimeout(timeout);
resolve(msg);
},
reject: (err) => {
clearTimeout(timeout);
reject(err);
}
});
ws.send(JSON.stringify({ ...payload, requestId }));
});
}
async function loadNextPage() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
if (isLoadingPage || !hasMoreServer) return;
isLoadingPage = true;
setListLoading(true);
try {
const offset = allRecent.length;
const msg = await sendWs({ type: 'request-pastes', offset, limit: PAGE_SIZE });
if (msg.type === 'pastes-page') {
handleRealtimePage(msg.data, false);
}
} catch (err) {
list.textContent = err.message;
setCreateState(err.message, true);
} finally {
isLoadingPage = false;
setListLoading(false);
}
}
@@ -70,24 +168,24 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
setCreateState('Creating secure link...');
try {
const payload = {
title: document.getElementById('title').value,
language: document.getElementById('language').value,
expiresHours: Number(document.getElementById('expiresHours').value),
maxReads: Number(document.getElementById('maxReads').value),
destroyOnRead: document.getElementById('destroyOnRead').checked,
content: document.getElementById('content').value
destroyOnRead: document.getElementById('destroyOnRead').checked
};
const data = await api('/api/pastes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
shareLink.value = `${window.location.origin}${data.viewUrl}`;
const plain = document.getElementById('content').value;
if (!plain || !plain.trim()) {
throw new Error('content is required');
}
const encrypted = await encryptContent(plain);
payload.encrypted = encrypted.encrypted;
const msg = await sendWs({ type: 'create-paste', payload });
const data = msg.data;
shareLink.value = `${window.location.origin}${data.viewUrl}#k=${encodeURIComponent(encrypted.key)}`;
openBtn.href = data.viewUrl;
setCreateState(`Created. Expires in about ${msToHuman(data.paste.expiresInMs)}.`);
setCreateState(`Created encrypted paste. Expires in about ${msToHuman(data.paste.expiresInMs)}.`);
content.value = '';
content.focus();
await refreshList();
} catch (err) {
setCreateState(err.message, true);
} finally {
@@ -108,7 +206,6 @@ document.getElementById('copy-link-btn').addEventListener('click', async () => {
});
document.getElementById('clear-btn').addEventListener('click', () => {
document.getElementById('title').value = '';
document.getElementById('language').value = '';
document.getElementById('expiresHours').value = '24';
document.getElementById('maxReads').value = '0';
@@ -120,6 +217,21 @@ document.getElementById('clear-btn').addEventListener('click', () => {
document.getElementById('list').addEventListener('click', async (e) => {
const link = e.target.closest('[data-copy-url]');
const destroy = e.target.closest('[data-destroy-id]');
if (destroy) {
e.preventDefault();
const pasteId = destroy.getAttribute('data-destroy-id');
if (!pasteId) return;
if (!confirm(`Destroy paste ${pasteId}?`)) return;
try {
await sendWs({ type: 'destroy-paste', pasteId });
setCreateState(`Destroyed ${pasteId}.`);
} catch (err) {
setCreateState(err.message, true);
}
return;
}
if (!link) return;
e.preventDefault();
try {
@@ -131,5 +243,49 @@ document.getElementById('list').addEventListener('click', async (e) => {
}
});
document.getElementById('refresh-btn').addEventListener('click', refreshList);
refreshList();
function connectWebSocket() {
ws = new WebSocket(wsUrl());
ws.addEventListener('open', () => {
document.getElementById('live-dot').textContent = 'LIVE';
});
ws.addEventListener('close', () => {
document.getElementById('live-dot').textContent = 'RECONNECTING';
setTimeout(connectWebSocket, 1200);
});
ws.addEventListener('message', (evt) => {
let msg;
try {
msg = JSON.parse(evt.data);
} catch {
return;
}
if (msg.requestId && pending.has(msg.requestId)) {
const req = pending.get(msg.requestId);
pending.delete(msg.requestId);
if (msg.type === 'error') req.reject(new Error(msg.error || 'Realtime error'));
else req.resolve(msg);
return;
}
if (msg.type === 'init' || msg.type === 'pastes-updated') {
handleRealtimePage(msg.data, true);
}
if (msg.type === 'paste-destroyed') {
const removedId = msg.data && msg.data.id;
if (removedId) {
allRecent = allRecent.filter((p) => p.id !== removedId);
visibleCount = Math.min(Math.max(visibleCount - 1, PAGE_SIZE), allRecent.length);
renderRecentList();
}
}
});
}
window.addEventListener('scroll', () => {
const nearBottom = window.innerHeight + window.scrollY >= document.body.offsetHeight - 280;
if (nearBottom) loadNextPage();
});
connectWebSocket();
+19
View File
@@ -182,6 +182,15 @@ button:hover,
margin-bottom: 0.5rem;
}
.live-dot {
font-size: 0.74rem;
letter-spacing: 0.08em;
color: #22c55e;
border: 1px solid rgba(34, 197, 94, 0.45);
border-radius: 999px;
padding: 0.2rem 0.5rem;
}
.list-head h2 {
margin: 0;
font-size: 1rem;
@@ -217,6 +226,16 @@ button:hover,
font-size: 0.9rem;
}
.danger {
color: #fca5a5;
}
.list-loading {
margin-top: 0.6rem;
color: #9fb0cb;
font-size: 0.9rem;
}
a {
color: #7db1ff;
}