forked from snxraven/p2ns
E2EE for peer.paste
This commit is contained in:
@@ -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) => ({ '<': '<', '>': '>', '&': '&' }[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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user