peer paste updates

This commit is contained in:
Raven Scott
2026-05-27 23:08:45 -04:00
parent 1974145b79
commit 30153bf958
8 changed files with 570 additions and 33 deletions
+12 -2
View File
@@ -7,7 +7,10 @@
- Store paste metadata/content in HyperDB. - Store paste metadata/content in HyperDB.
- Share via link (`/p/:id`) or API (`/api/pastes/:id/raw`). - Share via link (`/p/:id`) or API (`/api/pastes/:id/raw`).
- Supports expiration, burn-after-read, and max-read limits. - Supports expiration, burn-after-read, and max-read limits.
- Includes a lightweight web UI for create/list flows. - Includes a websocket-first lightweight web UI with infinite scroll.
- Supports `text` and `markdown` formats with client preview.
- Uses encryption by default with optional public mode and optional passphrase-derived mode.
- Supports bounded inline attachments metadata/payloads.
## Routes ## Routes
@@ -16,6 +19,8 @@
- `GET /api/pastes/mine` list active pastes owned by local peer - `GET /api/pastes/mine` list active pastes owned by local peer
- `GET /api/pastes/:id` get paste metadata - `GET /api/pastes/:id` get paste metadata
- `GET /api/pastes/:id/raw` consume/read paste content - `GET /api/pastes/:id/raw` consume/read paste content
- `GET /api/pastes/:id/attachments` list attachment metadata
- `GET /api/pastes/:id/attachments/:attachmentId` get attachment payload
- `PUT /api/pastes/:id` update paste (owner only; authenticated) - `PUT /api/pastes/:id` update paste (owner only; authenticated)
- `DELETE /api/pastes/:id` delete paste (owner only; authenticated) - `DELETE /api/pastes/:id` delete paste (owner only; authenticated)
- `GET /api/stats` get aggregate service stats - `GET /api/stats` get aggregate service stats
@@ -31,10 +36,14 @@ Collection: `@peerpaste/pastes`
Fields: Fields:
- `id` (string, key) - `id` (string, key)
- `title` (string)
- `content` (string) - `content` (string)
- `format` (string: `text` or `markdown`)
- `language` (string) - `language` (string)
- `ownerPeerId` (string) - `ownerPeerId` (string)
- `isPublic` (bool)
- `encryptionMode` (string)
- `checksum` (string)
- `attachmentRefs` (stringified JSON array)
- `destroyOnRead` (bool) - `destroyOnRead` (bool)
- `maxReads` (uint) - `maxReads` (uint)
- `readCount` (uint) - `readCount` (uint)
@@ -57,3 +66,4 @@ Index:
- Raw consumption increments read count and can auto-delete on read constraints. - Raw consumption increments read count and can auto-delete on read constraints.
- The `/api/docs` endpoint provides copy/paste-ready endpoint summaries. - The `/api/docs` endpoint provides copy/paste-ready endpoint summaries.
- The `/api/openapi.json` endpoint can be imported by API tooling. - The `/api/openapi.json` endpoint can be imported by API tooling.
- Burn-after-first-read entries are hidden from recent active websocket list views.
+17
View File
@@ -7,6 +7,11 @@ Temporary P2P text snippets for the P2NS network.
- Expiring pastes (default 24h, max 7 days) - Expiring pastes (default 24h, max 7 days)
- Optional burn-after-first-read - Optional burn-after-first-read
- Optional max read count - Optional max read count
- Realtime websocket-driven UI updates
- Text + markdown formats with preview support
- Encryption by default (AES-GCM), optional public paste mode
- Optional passphrase-derived encryption mode
- Lightweight inline attachments (bounded)
- Public share links (`/p/:id`) - Public share links (`/p/:id`)
- Raw API access (`/api/pastes/:id/raw`) - Raw API access (`/api/pastes/:id/raw`)
- Metadata replication via HyperDB - Metadata replication via HyperDB
@@ -18,6 +23,8 @@ Temporary P2P text snippets for the P2NS network.
- `GET /api/pastes/mine` list active pastes owned by local peer - `GET /api/pastes/mine` list active pastes owned by local peer
- `GET /api/pastes/:id` metadata - `GET /api/pastes/:id` metadata
- `GET /api/pastes/:id/raw` consume/read paste content - `GET /api/pastes/:id/raw` consume/read paste content
- `GET /api/pastes/:id/attachments` list attachment metadata
- `GET /api/pastes/:id/attachments/:attachmentId` get attachment payload
- `PUT /api/pastes/:id` update paste (owner only, authenticated) - `PUT /api/pastes/:id` update paste (owner only, authenticated)
- `DELETE /api/pastes/:id` delete paste (owner only, authenticated) - `DELETE /api/pastes/:id` delete paste (owner only, authenticated)
- `GET /api/stats` service stats - `GET /api/stats` service stats
@@ -31,3 +38,13 @@ Temporary P2P text snippets for the P2NS network.
- Pasted content is stored in HyperDB and replicates with peers. - Pasted content is stored in HyperDB and replicates with peers.
- A cleanup task runs hourly to purge expired/consumed entries. - A cleanup task runs hourly to purge expired/consumed entries.
- Burn-after-first-read pastes are intentionally excluded from recent active lists.
## Realtime WS Messages
- Client -> server: `request-pastes`, `create-paste`, `destroy-paste`
- Server -> client: `init`, `pastes-page`, `pastes-updated`, `paste-created`, `paste-destroyed`, `error`
## Validation Script
- Run `node test-scripts/peer-paste-smoke.js https://peer.paste` to validate core API/OpenAPI availability.
+6 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "Peer Paste", "name": "Peer Paste",
"version": "1.0.3", "version": "1.1.0",
"domain": "peer.paste", "domain": "peer.paste",
"enabled": true, "enabled": true,
"description": "Temporary P2P text snippets with expiration and burn-after-read options", "description": "Temporary P2P text snippets with expiration and burn-after-read options",
@@ -18,10 +18,14 @@
"compact": true, "compact": true,
"fields": [ "fields": [
{ "name": "id", "type": "string", "required": true }, { "name": "id", "type": "string", "required": true },
{ "name": "title", "type": "string", "required": false },
{ "name": "content", "type": "string", "required": true }, { "name": "content", "type": "string", "required": true },
{ "name": "format", "type": "string", "required": false },
{ "name": "language", "type": "string", "required": false }, { "name": "language", "type": "string", "required": false },
{ "name": "ownerPeerId", "type": "string", "required": false }, { "name": "ownerPeerId", "type": "string", "required": false },
{ "name": "isPublic", "type": "bool", "required": false },
{ "name": "encryptionMode", "type": "string", "required": false },
{ "name": "checksum", "type": "string", "required": false },
{ "name": "attachmentRefs", "type": "string", "required": false },
{ "name": "destroyOnRead", "type": "bool", "required": false }, { "name": "destroyOnRead", "type": "bool", "required": false },
{ "name": "maxReads", "type": "uint", "required": false }, { "name": "maxReads", "type": "uint", "required": false },
{ "name": "readCount", "type": "uint", "required": true }, { "name": "readCount", "type": "uint", "required": true },
+259 -18
View File
@@ -8,11 +8,20 @@ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
const MAX_CONTENT_CHARS = 100000; const MAX_CONTENT_CHARS = 100000;
const MAX_LANGUAGE_CHARS = 32; const MAX_LANGUAGE_CHARS = 32;
const MAX_MAXREADS = 1000000; const MAX_MAXREADS = 1000000;
const MAX_ATTACHMENTS = 4;
const MAX_ATTACHMENT_BYTES = 256 * 1024;
const MAX_LIST_SCAN = 500; const MAX_LIST_SCAN = 500;
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const ENC_PREFIX = 'ENCv1:'; const ENC_PREFIX = 'ENCv1:';
let cleanupTimer = null; let cleanupTimer = null;
const metrics = {
queryTimeoutFallbacks: 0,
wsMessagesHandled: 0,
wsErrors: 0,
createCount: 0,
destroyCount: 0
};
function now() { function now() {
return Date.now(); return Date.now();
@@ -32,12 +41,51 @@ function sanitizeText(v, fallback = '') {
return typeof v === 'string' ? v : fallback; return typeof v === 'string' ? v : fallback;
} }
function normalizeFormat(v) {
const raw = sanitizeText(v, 'text').toLowerCase();
if (raw === 'markdown' || raw === 'md') return 'markdown';
return 'text';
}
function normalizeAttachmentList(input) {
if (!Array.isArray(input)) return [];
return input
.slice(0, MAX_ATTACHMENTS)
.map((it) => ({
id: sanitizeText(it.id || ''),
name: sanitizeText(it.name || 'attachment'),
mime: sanitizeText(it.mime || 'application/octet-stream'),
size: Math.max(0, Number(it.size) || 0),
data: sanitizeText(it.data || '')
}))
.filter((it) => it.id && it.data && it.size > 0 && it.size <= MAX_ATTACHMENT_BYTES);
}
function normalizeStoredPaste(paste) {
if (!paste) return paste;
return {
...paste,
format: normalizeFormat(paste.format),
isPublic: paste.isPublic === true,
encryptionMode: sanitizeText(paste.encryptionMode || 'none'),
checksum: sanitizeText(paste.checksum || ''),
attachmentRefs: sanitizeText(paste.attachmentRefs || '[]')
};
}
async function computeChecksum(input) {
const h = crypto.createHash('sha256');
h.update(input);
return h.digest('hex');
}
function encodeEncryptedContent(payload) { function encodeEncryptedContent(payload) {
const safe = { const safe = {
v: 1, v: 1,
alg: 'A256GCM', alg: sanitizeText(payload.alg || 'A256GCM'),
iv: sanitizeText(payload.iv), iv: sanitizeText(payload.iv),
ciphertext: sanitizeText(payload.ciphertext) ciphertext: sanitizeText(payload.ciphertext),
salt: sanitizeText(payload.salt || '')
}; };
if (!safe.iv || !safe.ciphertext) { if (!safe.iv || !safe.ciphertext) {
throw new Error('encrypted payload must include iv and ciphertext'); throw new Error('encrypted payload must include iv and ciphertext');
@@ -62,6 +110,9 @@ function normalizePasteInput(body = {}, existing = null) {
const content = body.content !== undefined ? sanitizeText(body.content) : (existing ? existing.content : ''); const content = body.content !== undefined ? sanitizeText(body.content) : (existing ? existing.content : '');
const encrypted = body.encrypted && typeof body.encrypted === 'object' ? body.encrypted : null; const encrypted = body.encrypted && typeof body.encrypted === 'object' ? body.encrypted : null;
const isPublic = body.public === true; const isPublic = body.public === true;
const format = normalizeFormat(body.format || (existing && existing.format) || 'text');
const encryptionMode = sanitizeText(body.encryptionMode || (isPublic ? 'none' : 'aes-gcm'));
const attachments = normalizeAttachmentList(body.attachments);
const destroyOnRead = body.destroyOnRead !== undefined ? !!body.destroyOnRead : (existing ? !!existing.destroyOnRead : false); const destroyOnRead = body.destroyOnRead !== undefined ? !!body.destroyOnRead : (existing ? !!existing.destroyOnRead : false);
const maxReadsRaw = body.maxReads !== undefined ? body.maxReads : (existing ? existing.maxReads : 0); 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 maxReads = Math.max(0, Math.min(MAX_MAXREADS, Math.floor(Number(maxReadsRaw) || 0)));
@@ -72,6 +123,9 @@ function normalizePasteInput(body = {}, existing = null) {
content, content,
encrypted, encrypted,
isPublic, isPublic,
format,
encryptionMode,
attachments,
destroyOnRead, destroyOnRead,
maxReads, maxReads,
expiresHours expiresHours
@@ -82,7 +136,17 @@ function toPublicPaste(paste, includeOwner = false) {
const t = now(); const t = now();
const out = { const out = {
id: paste.id, id: paste.id,
format: normalizeFormat(paste.format || 'text'),
language: paste.language || '', language: paste.language || '',
isPublic: paste.isPublic === true,
encryptionMode: paste.encryptionMode || 'none',
attachmentCount: (() => {
try {
return JSON.parse(paste.attachmentRefs || '[]').length;
} catch {
return 0;
}
})(),
destroyOnRead: !!paste.destroyOnRead, destroyOnRead: !!paste.destroyOnRead,
maxReads: paste.maxReads || 0, maxReads: paste.maxReads || 0,
readCount: paste.readCount || 0, readCount: paste.readCount || 0,
@@ -150,6 +214,7 @@ async function safeFindPastes(options = {}) {
return { rows, timedOut: false }; return { rows, timedOut: false };
} catch (err) { } catch (err) {
if (isQueryTimeoutError(err)) { if (isQueryTimeoutError(err)) {
metrics.queryTimeoutFallbacks += 1;
sdk.log.warn('peer.paste', `${context}: timeout persisted, returning empty fallback`); sdk.log.warn('peer.paste', `${context}: timeout persisted, returning empty fallback`);
return { rows: [], timedOut: true }; return { rows: [], timedOut: true };
} }
@@ -224,12 +289,19 @@ async function createPaste(req, res) {
const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000; const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000;
const ownerPeerId = sdk.state.localPeerId || ''; const ownerPeerId = sdk.state.localPeerId || '';
const attachmentRefs = input.attachments;
const payloadChecksum = await computeChecksum(`${storedContent}:${JSON.stringify(attachmentRefs)}:${input.format}`);
const paste = { const paste = {
id: generatePasteId(), id: generatePasteId(),
title: '', title: '',
content: storedContent, content: storedContent,
format: input.format,
language: input.language, language: input.language,
ownerPeerId, ownerPeerId,
isPublic: input.isPublic,
encryptionMode: input.encryptionMode,
checksum: payloadChecksum,
attachmentRefs: JSON.stringify(attachmentRefs),
destroyOnRead: input.destroyOnRead, destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads, maxReads: input.maxReads,
readCount: 0, readCount: 0,
@@ -261,7 +333,7 @@ async function listPastes(req, res, ownerOnly = false) {
const ownerPeerId = sdk.state.localPeerId || ''; const ownerPeerId = sdk.state.localPeerId || '';
const search = sanitizeText(req.query?.search || '').toLowerCase(); const search = sanitizeText(req.query?.search || '').toLowerCase();
let items = all.filter((p) => p.expiresAt > t); let items = all.map(normalizeStoredPaste).filter((p) => p.expiresAt > t);
if (ownerOnly) items = items.filter((p) => (p.ownerPeerId || '') === ownerPeerId); if (ownerOnly) items = items.filter((p) => (p.ownerPeerId || '') === ownerPeerId);
// Burn-after-first-read pastes should never appear in recent listings. // Burn-after-first-read pastes should never appear in recent listings.
items = items.filter((p) => !p.destroyOnRead); items = items.filter((p) => !p.destroyOnRead);
@@ -287,16 +359,55 @@ async function listPastes(req, res, ownerOnly = false) {
} }
async function getPasteMetadata(res, pasteId) { async function getPasteMetadata(res, pasteId) {
const paste = await sdk.db.get(COLLECTION, { id: pasteId }); const raw = await sdk.db.get(COLLECTION, { id: pasteId });
const paste = normalizeStoredPaste(raw);
if (!paste) return sdk.router.notFound(res, 'Paste not found'); if (!paste) return sdk.router.notFound(res, 'Paste not found');
let attachments = [];
try {
attachments = JSON.parse(paste.attachmentRefs || '[]').map((a) => ({
id: a.id,
name: a.name,
mime: a.mime,
size: a.size
}));
} catch {
attachments = [];
}
return sdk.router.json(res, { return sdk.router.json(res, {
paste: toPublicPaste(paste, true), paste: toPublicPaste(paste, true),
attachments,
consumable: isConsumable(paste) consumable: isConsumable(paste)
}); });
} }
async function getPasteAttachments(res, pasteId, attachmentId = null) {
const raw = await sdk.db.get(COLLECTION, { id: pasteId });
const paste = normalizeStoredPaste(raw);
if (!paste) return sdk.router.notFound(res, 'Paste not found');
let attachments = [];
try {
attachments = JSON.parse(paste.attachmentRefs || '[]');
} catch {
attachments = [];
}
if (!attachmentId) {
return sdk.router.json(res, {
attachments: attachments.map((a) => ({
id: a.id,
name: a.name,
mime: a.mime,
size: a.size
}))
});
}
const att = attachments.find((a) => a.id === attachmentId);
if (!att) return sdk.router.notFound(res, 'Attachment not found');
return sdk.router.json(res, { attachment: att });
}
async function consumePaste(res, pasteId) { async function consumePaste(res, pasteId) {
const paste = await sdk.db.get(COLLECTION, { id: pasteId }); const raw = await sdk.db.get(COLLECTION, { id: pasteId });
const paste = normalizeStoredPaste(raw);
if (!paste || !isConsumable(paste)) { if (!paste || !isConsumable(paste)) {
return sdk.router.notFound(res, 'Paste not found or expired'); return sdk.router.notFound(res, 'Paste not found or expired');
} }
@@ -315,12 +426,20 @@ async function consumePaste(res, pasteId) {
await sdk.db.flush(); await sdk.db.flush();
const encryptedPayload = decodeEncryptedContent(paste.content); const encryptedPayload = decodeEncryptedContent(paste.content);
let attachments = [];
try {
attachments = JSON.parse(paste.attachmentRefs || '[]');
} catch {
attachments = [];
}
return sdk.router.json(res, { return sdk.router.json(res, {
paste: { paste: {
...toPublicPaste({ ...paste, readCount: nextReadCount }, true), ...toPublicPaste({ ...paste, readCount: nextReadCount }, true),
encrypted: !!encryptedPayload, encrypted: !!encryptedPayload,
encryptedPayload: encryptedPayload || undefined, encryptedPayload: encryptedPayload || undefined,
content: encryptedPayload ? undefined : paste.content content: encryptedPayload ? undefined : paste.content,
format: normalizeFormat(paste.format || 'text'),
attachments
}, },
consumed consumed
}); });
@@ -330,7 +449,7 @@ async function updatePaste(req, res, pasteId) {
const localPeer = await sdk.auth.requireLocalPeer(req, res); const localPeer = await sdk.auth.requireLocalPeer(req, res);
if (!localPeer) return true; if (!localPeer) return true;
const existing = await sdk.db.get(COLLECTION, { id: pasteId }); const existing = normalizeStoredPaste(await sdk.db.get(COLLECTION, { id: pasteId }));
if (!existing) return sdk.router.notFound(res, 'Paste not found'); if (!existing) return sdk.router.notFound(res, 'Paste not found');
if (existing.ownerPeerId && existing.ownerPeerId !== localPeer) { if (existing.ownerPeerId && existing.ownerPeerId !== localPeer) {
return sdk.router.forbidden(res, 'Only the owner can update this paste'); return sdk.router.forbidden(res, 'Only the owner can update this paste');
@@ -350,11 +469,24 @@ async function updatePaste(req, res, pasteId) {
return sdk.router.badRequest(res, 'plaintext content updates are not allowed'); return sdk.router.badRequest(res, 'plaintext content updates are not allowed');
} }
let existingAttachments = [];
try {
existingAttachments = JSON.parse(existing.attachmentRefs || '[]');
} catch {
existingAttachments = [];
}
const nextAttachments = input.attachments.length > 0 ? input.attachments : existingAttachments;
const nextChecksum = await computeChecksum(`${newContent}:${JSON.stringify(nextAttachments)}:${input.format}`);
const updated = { const updated = {
...existing, ...existing,
title: '', title: '',
format: input.format,
language: input.language, language: input.language,
content: newContent, content: newContent,
isPublic: input.isPublic,
encryptionMode: input.encryptionMode,
checksum: nextChecksum,
attachmentRefs: JSON.stringify(nextAttachments),
destroyOnRead: input.destroyOnRead, destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads, maxReads: input.maxReads,
expiresAt: input.expiresHours expiresAt: input.expiresHours
@@ -375,7 +507,7 @@ async function deletePasteRoute(req, res, pasteId) {
const localPeer = await sdk.auth.requireLocalPeer(req, res); const localPeer = await sdk.auth.requireLocalPeer(req, res);
if (!localPeer) return true; if (!localPeer) return true;
const paste = await sdk.db.get(COLLECTION, { id: pasteId }); const paste = normalizeStoredPaste(await sdk.db.get(COLLECTION, { id: pasteId }));
if (!paste) return sdk.router.notFound(res, 'Paste not found'); if (!paste) return sdk.router.notFound(res, 'Paste not found');
if (paste.ownerPeerId && paste.ownerPeerId !== localPeer) { if (paste.ownerPeerId && paste.ownerPeerId !== localPeer) {
return sdk.router.forbidden(res, 'Only the owner can delete this paste'); return sdk.router.forbidden(res, 'Only the owner can delete this paste');
@@ -410,11 +542,15 @@ async function getStats(res) {
totalReads, totalReads,
mine, mine,
timestamp: t, timestamp: t,
degraded: timedOut degraded: timedOut,
metrics
}); });
} }
function renderPastePage(paste) { function renderPastePage(paste) {
const safePaste = normalizeStoredPaste(paste);
let attachments = [];
try { attachments = JSON.parse(safePaste.attachmentRefs || '[]'); } catch {}
const encryptedPayload = decodeEncryptedContent(paste.content); const encryptedPayload = decodeEncryptedContent(paste.content);
if (encryptedPayload) { if (encryptedPayload) {
return `<!doctype html> return `<!doctype html>
@@ -433,17 +569,19 @@ function renderPastePage(paste) {
.key-row{display:flex;gap:.5rem;margin:.75rem 0} .key-row{display:flex;gap:.5rem;margin:.75rem 0}
input{flex:1;background:#020617;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:.5rem} input{flex:1;background:#020617;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:.5rem}
button{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:.5rem .85rem;cursor:pointer} button{background:#2563eb;color:#fff;border:0;border-radius:8px;padding:.5rem .85rem;cursor:pointer}
.preview{white-space:pre-wrap;word-break:break-word}
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>paste ${paste.id}</h1> <h1>paste ${paste.id}</h1>
<div class="meta">Encrypted paste. Key must be present in URL fragment.</div> <div class="meta">Encrypted ${safePaste.format} paste (${safePaste.encryptionMode || 'aes-gcm'}). Key must be present in URL fragment.</div>
<div class="key-row"> <div class="key-row">
<input id="manual-key" placeholder="Paste key (base64url) if missing from URL" /> <input id="manual-key" placeholder="Paste key (base64url) if missing from URL" />
<button id="decrypt-btn" type="button">Decrypt</button> <button id="decrypt-btn" type="button">Decrypt</button>
</div> </div>
<div class="card"><pre id="out">Decrypting...</pre></div> <div class="card"><div id="out" class="preview">Decrypting...</div></div>
${attachments.length ? `<p class="meta">Attachments: ${attachments.map((a) => a.name).join(', ')}</p>` : ''}
<p><a href="/">Create another paste</a></p> <p><a href="/">Create another paste</a></p>
</div> </div>
<script> <script>
@@ -474,7 +612,12 @@ function renderPastePage(paste) {
const ciphertext = b64uToBytes(payload.ciphertext); const ciphertext = b64uToBytes(payload.ciphertext);
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']); 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); const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
out.textContent = new TextDecoder().decode(plain); const text = new TextDecoder().decode(plain);
if (${JSON.stringify(safePaste.format)} === 'markdown' && window.marked && window.DOMPurify) {
out.innerHTML = DOMPurify.sanitize(marked.parse(text));
} else {
out.textContent = text;
}
} catch (err) { } catch (err) {
out.textContent = 'Failed to decrypt paste: ' + (err && err.message ? err.message : 'unknown error'); out.textContent = 'Failed to decrypt paste: ' + (err && err.message ? err.message : 'unknown error');
} }
@@ -493,11 +636,13 @@ function renderPastePage(paste) {
decryptAndRender(''); decryptAndRender('');
} }
</script> </script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
</body> </body>
</html>`; </html>`;
} }
const language = paste.language || 'plain'; const language = safePaste.language || 'plain';
const expiresIn = Math.max(0, paste.expiresAt - now()); const expiresIn = Math.max(0, paste.expiresAt - now());
const hours = Math.floor(expiresIn / (1000 * 60 * 60)); const hours = Math.floor(expiresIn / (1000 * 60 * 60));
const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60)); const minutes = Math.floor((expiresIn % (1000 * 60 * 60)) / (1000 * 60));
@@ -519,10 +664,22 @@ function renderPastePage(paste) {
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>paste ${paste.id}</h1> <h1>paste ${paste.id}</h1>
<div class="meta">Language: ${language} | Expires in: ${hours}h ${minutes}m</div> <div class="meta">Language: ${language} | Format: ${safePaste.format} | Expires in: ${hours}h ${minutes}m</div>
<div class="card"><pre>${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</pre></div> <div class="card">${safePaste.format === 'markdown'
? `<div id="md-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</div>`
: `<pre>${paste.content.replace(/[<>&]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[m]))}</pre>`}</div>
${attachments.length ? `<div class="card"><h3>Attachments</h3>${attachments.map((a) => `<p><a download="${a.name}" href="${a.data}">${a.name}</a> (${a.size} bytes)</p>`).join('')}</div>` : ''}
<p><a href="/">Create another paste</a></p> <p><a href="/">Create another paste</a></p>
</div> </div>
${safePaste.format === 'markdown'
? `<script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
<script>
const el = document.getElementById('md-render');
const src = el.textContent || '';
el.innerHTML = DOMPurify.sanitize(marked.parse(src));
</script>`
: ''}
</body> </body>
</html>`; </html>`;
} }
@@ -570,8 +727,13 @@ function apiDocsHtml(baseUrl) {
<h2><span class="m POST">POST</span>/api/pastes</h2> <h2><span class="m POST">POST</span>/api/pastes</h2>
<p>Create a paste.</p> <p>Create a paste.</p>
<pre>{ <pre>{
"public": false,
"format": "text|markdown",
"language": "plain|js|json|md", "language": "plain|js|json|md",
"encryptionMode": "aes-gcm|passphrase-aes-gcm",
"encrypted": { "iv": "base64url", "ciphertext": "base64url" }, "encrypted": { "iv": "base64url", "ciphertext": "base64url" },
"content": "required if public=true",
"attachments": [{ "id":"..","name":"file.txt","mime":"text/plain","size":123,"data":"data:..." }],
"expiresHours": 24, "expiresHours": 24,
"destroyOnRead": false, "destroyOnRead": false,
"maxReads": 0 "maxReads": 0
@@ -589,8 +751,12 @@ function apiDocsHtml(baseUrl) {
<h2><span class="m PUT">PUT</span>/api/pastes/:id</h2> <h2><span class="m PUT">PUT</span>/api/pastes/:id</h2>
<p>Update a paste (owner only, auth required).</p> <p>Update a paste (owner only, auth required).</p>
<pre>{ <pre>{
"public": false,
"format": "text|markdown",
"language": "optional", "language": "optional",
"encrypted": { "iv": "base64url", "ciphertext": "base64url" }, "encrypted": { "iv": "base64url", "ciphertext": "base64url" },
"content": "optional if public=true",
"attachments": [/* optional replacement list */],
"expiresHours": 12, "expiresHours": 12,
"destroyOnRead": true, "destroyOnRead": true,
"maxReads": 10 "maxReads": 10
@@ -600,6 +766,14 @@ function apiDocsHtml(baseUrl) {
<h2><span class="m DELETE">DELETE</span>/api/pastes/:id</h2> <h2><span class="m DELETE">DELETE</span>/api/pastes/:id</h2>
<p>Delete a paste (owner only, auth required).</p> <p>Delete a paste (owner only, auth required).</p>
</div> </div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/:id/attachments</h2>
<p>List attachment metadata for a paste.</p>
</div>
<div class="card">
<h2><span class="m GET">GET</span>/api/pastes/:id/attachments/:attachmentId</h2>
<p>Get a specific attachment payload.</p>
</div>
<div class="card"> <div class="card">
<h2><span class="m POST">POST</span>/api/admin/cleanup</h2> <h2><span class="m POST">POST</span>/api/admin/cleanup</h2>
<p>Trigger cleanup immediately (auth required).</p> <p>Trigger cleanup immediately (auth required).</p>
@@ -693,6 +867,23 @@ function buildOpenApi(baseUrl) {
responses: { 200: { description: 'Paste content' }, 404: { description: 'Not found or expired' } } responses: { 200: { description: 'Paste content' }, 404: { description: 'Not found or expired' } }
} }
}, },
'/api/pastes/{id}/attachments': {
get: {
summary: 'List attachments for a paste',
parameters: [{ $ref: '#/components/parameters/PasteId' }],
responses: { 200: { description: 'Attachment metadata list' } }
}
},
'/api/pastes/{id}/attachments/{attachmentId}': {
get: {
summary: 'Get attachment payload',
parameters: [
{ $ref: '#/components/parameters/PasteId' },
{ name: 'attachmentId', in: 'path', required: true, schema: { type: 'string' } }
],
responses: { 200: { description: 'Attachment payload' }, 404: { description: 'Not found' } }
}
},
'/api/admin/cleanup': { '/api/admin/cleanup': {
post: { post: {
summary: 'Trigger cleanup (authenticated)', summary: 'Trigger cleanup (authenticated)',
@@ -726,7 +917,7 @@ function buildOpenApi(baseUrl) {
schemas: { schemas: {
CreatePasteRequest: { CreatePasteRequest: {
type: 'object', type: 'object',
required: ['content'], required: [],
properties: { properties: {
language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS }, language: { type: 'string', maxLength: MAX_LANGUAGE_CHARS },
encrypted: { encrypted: {
@@ -734,9 +925,18 @@ function buildOpenApi(baseUrl) {
required: ['iv', 'ciphertext'], required: ['iv', 'ciphertext'],
properties: { properties: {
iv: { type: 'string' }, iv: { type: 'string' },
ciphertext: { type: 'string' } ciphertext: { type: 'string' },
salt: { type: 'string' }
} }
}, },
public: { type: 'boolean' },
format: { type: 'string', enum: ['text', 'markdown'] },
encryptionMode: { type: 'string' },
content: { type: 'string' },
attachments: {
type: 'array',
items: { $ref: '#/components/schemas/Attachment' }
},
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS }, expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' }, destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS } maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
@@ -751,13 +951,32 @@ function buildOpenApi(baseUrl) {
required: ['iv', 'ciphertext'], required: ['iv', 'ciphertext'],
properties: { properties: {
iv: { type: 'string' }, iv: { type: 'string' },
ciphertext: { type: 'string' } ciphertext: { type: 'string' },
salt: { type: 'string' }
} }
}, },
public: { type: 'boolean' },
format: { type: 'string', enum: ['text', 'markdown'] },
encryptionMode: { type: 'string' },
content: { type: 'string' },
attachments: {
type: 'array',
items: { $ref: '#/components/schemas/Attachment' }
},
expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS }, expiresHours: { type: 'integer', minimum: 1, maximum: MAX_EXPIRES_HOURS },
destroyOnRead: { type: 'boolean' }, destroyOnRead: { type: 'boolean' },
maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS } maxReads: { type: 'integer', minimum: 0, maximum: MAX_MAXREADS }
} }
},
Attachment: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
mime: { type: 'string' },
size: { type: 'integer' },
data: { type: 'string' }
}
} }
} }
} }
@@ -815,6 +1034,7 @@ function setupWebSocketHandlers() {
sdk.websocket.on('message', async (ws, message) => { sdk.websocket.on('message', async (ws, message) => {
try { try {
metrics.wsMessagesHandled += 1;
if (message.type === 'request-pastes') { if (message.type === 'request-pastes') {
const offset = Math.max(0, Number(message.offset) || 0); const offset = Math.max(0, Number(message.offset) || 0);
const limit = Math.max(1, Math.min(100, Number(message.limit) || DEFAULT_PAGE_SIZE)); const limit = Math.max(1, Math.min(100, Number(message.limit) || DEFAULT_PAGE_SIZE));
@@ -846,12 +1066,19 @@ function setupWebSocketHandlers() {
const createdAt = now(); const createdAt = now();
const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000; const expiresAt = createdAt + (input.expiresHours || DEFAULT_EXPIRES_HOURS) * 60 * 60 * 1000;
const attachmentRefs = input.attachments || [];
const payloadChecksum = await computeChecksum(`${storedContent}:${JSON.stringify(attachmentRefs)}:${input.format}`);
const paste = { const paste = {
id: generatePasteId(), id: generatePasteId(),
title: '', title: '',
content: storedContent, content: storedContent,
format: input.format,
language: input.language, language: input.language,
ownerPeerId: sdk.state.localPeerId || '', ownerPeerId: sdk.state.localPeerId || '',
isPublic: input.isPublic,
encryptionMode: input.encryptionMode,
checksum: payloadChecksum,
attachmentRefs: JSON.stringify(attachmentRefs),
destroyOnRead: input.destroyOnRead, destroyOnRead: input.destroyOnRead,
maxReads: input.maxReads, maxReads: input.maxReads,
readCount: 0, readCount: 0,
@@ -861,6 +1088,7 @@ function setupWebSocketHandlers() {
await sdk.db.insert(COLLECTION, paste); await sdk.db.insert(COLLECTION, paste);
await sdk.db.flush(); await sdk.db.flush();
metrics.createCount += 1;
sdk.websocket.send(ws, { sdk.websocket.send(ws, {
type: 'paste-created', type: 'paste-created',
@@ -894,10 +1122,12 @@ function setupWebSocketHandlers() {
} }
await deletePaste(paste); await deletePaste(paste);
await sdk.db.flush(); await sdk.db.flush();
metrics.destroyCount += 1;
sdk.websocket.send(ws, { type: 'paste-destroyed', requestId: message.requestId || null, data: { id: pasteId } }); sdk.websocket.send(ws, { type: 'paste-destroyed', requestId: message.requestId || null, data: { id: pasteId } });
await broadcastRecentPastesUpdate(); await broadcastRecentPastesUpdate();
} }
} catch (err) { } catch (err) {
metrics.wsErrors += 1;
sdk.websocket.send(ws, { type: 'error', error: err.message, requestId: message.requestId || null }); sdk.websocket.send(ws, { type: 'error', error: err.message, requestId: message.requestId || null });
} }
}); });
@@ -946,6 +1176,17 @@ async function handler(req, res) {
const pasteId = path.slice('api/pastes/'.length, -'/raw'.length); const pasteId = path.slice('api/pastes/'.length, -'/raw'.length);
return consumePaste(res, pasteId); return consumePaste(res, pasteId);
} }
if (path.startsWith('api/pastes/') && path.endsWith('/attachments') && method === 'GET') {
const pasteId = path.slice('api/pastes/'.length, -'/attachments'.length);
return getPasteAttachments(res, pasteId);
}
if (path.startsWith('api/pastes/') && path.includes('/attachments/') && method === 'GET') {
const suffix = path.slice('api/pastes/'.length);
const sep = suffix.indexOf('/attachments/');
const pasteId = suffix.slice(0, sep);
const attachmentId = suffix.slice(sep + '/attachments/'.length);
return getPasteAttachments(res, pasteId, attachmentId);
}
if (path.startsWith('api/pastes/') && method === 'GET') { if (path.startsWith('api/pastes/') && method === 'GET') {
const pasteId = path.slice('api/pastes/'.length); const pasteId = path.slice('api/pastes/'.length);
+29
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>peer.paste</title> <title>peer.paste</title>
<link rel="stylesheet" href="/styles.css" /> <link rel="stylesheet" href="/styles.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/styles/github-dark.min.css" />
</head> </head>
<body> <body>
<main class="page"> <main class="page">
@@ -16,7 +17,22 @@
<section class="panel"> <section class="panel">
<form id="create-form" class="create-form"> <form id="create-form" class="create-form">
<label for="content" class="label">Secret / Paste</label> <label for="content" class="label">Secret / Paste</label>
<div class="toolbar">
<select id="format">
<option value="text">Text</option>
<option value="markdown">Markdown</option>
</select>
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
<button type="button" id="toggle-preview" class="ghost">Preview</button>
<button type="button" id="copy-editor" class="ghost">Copy Text</button>
</div>
<textarea id="content" rows="14" required placeholder="Paste text here..."></textarea> <textarea id="content" rows="14" required placeholder="Paste text here..."></textarea>
<div id="preview-panel" class="preview-panel hidden">
<div id="preview-content">Preview will appear here.</div>
</div>
<details class="advanced"> <details class="advanced">
<summary>Advanced options</summary> <summary>Advanced options</summary>
@@ -38,6 +54,12 @@
<input type="checkbox" id="publicPaste" /> <input type="checkbox" id="publicPaste" />
Public paste (disable encryption) Public paste (disable encryption)
</label> </label>
<label>Passphrase (optional encryption key derivation)
<input id="passphrase" type="password" autocomplete="off" placeholder="Optional extra layer" />
</label>
<label>Attachments (max 4, 256KB each)
<input id="attachments" type="file" multiple />
</label>
</div> </div>
</details> </details>
@@ -68,6 +90,13 @@
<div id="list-loading" class="list-loading hidden">Loading more...</div> <div id="list-loading" class="list-loading hidden">Loading more...</div>
</section> </section>
</main> </main>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/languages/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/languages/json.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/languages/bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/languages/markdown.min.js"></script>
<script src="/main.js"></script> <script src="/main.js"></script>
</body> </body>
</html> </html>
+148 -11
View File
@@ -1,10 +1,3 @@
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
return data;
}
function escapeHtml(s) { function escapeHtml(s) {
return String(s).replace(/[<>&"]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[m])); return String(s).replace(/[<>&"]/g, (m) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[m]));
} }
@@ -35,6 +28,15 @@ function bytesToB64u(bytes) {
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
} }
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 encryptContent(plaintext) { async function encryptContent(plaintext) {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const keyBytes = crypto.getRandomValues(new Uint8Array(32)); const keyBytes = crypto.getRandomValues(new Uint8Array(32));
@@ -50,6 +52,35 @@ async function encryptContent(plaintext) {
}; };
} }
async function deriveKeyFromPassphrase(passphrase, saltBytes) {
const enc = new TextEncoder();
const material = await crypto.subtle.importKey('raw', enc.encode(passphrase), { name: 'PBKDF2' }, false, ['deriveKey']);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: saltBytes, iterations: 200000 },
material,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
async function encryptContentWithPassphrase(plaintext, passphrase) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveKeyFromPassphrase(passphrase, salt);
const keyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', key));
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)),
salt: bytesToB64u(salt)
}
};
}
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
let ws = null; let ws = null;
let requestCounter = 0; let requestCounter = 0;
@@ -58,6 +89,7 @@ let allRecent = [];
let visibleCount = 0; let visibleCount = 0;
let hasMoreServer = true; let hasMoreServer = true;
let isLoadingPage = false; let isLoadingPage = false;
let wsReconnectAttempts = 0;
function updateCreateButtonLabel() { function updateCreateButtonLabel() {
const publicPaste = document.getElementById('publicPaste').checked; const publicPaste = document.getElementById('publicPaste').checked;
@@ -65,6 +97,36 @@ function updateCreateButtonLabel() {
createBtn.textContent = publicPaste ? 'Create public link' : 'Create secure link'; createBtn.textContent = publicPaste ? 'Create public link' : 'Create secure link';
} }
function applyTheme() {
const theme = document.getElementById('theme').value;
document.body.classList.toggle('light', theme === 'light');
}
function renderPreview() {
const panel = document.getElementById('preview-panel');
const out = document.getElementById('preview-content');
if (panel.classList.contains('hidden')) return;
const text = document.getElementById('content').value || '';
const format = document.getElementById('format').value;
if (format === 'markdown') {
marked.setOptions({
gfm: true,
breaks: true,
highlight(code, lang) {
if (lang && window.hljs && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value;
}
return window.hljs ? hljs.highlightAuto(code).value : escapeHtml(code);
}
});
const html = marked.parse(text);
out.innerHTML = window.DOMPurify ? DOMPurify.sanitize(html) : html;
if (window.hljs) out.querySelectorAll('pre code').forEach((b) => hljs.highlightElement(b));
} else {
out.innerHTML = `<pre><code>${escapeHtml(text)}</code></pre>`;
}
}
function nextRequestId() { function nextRequestId() {
requestCounter += 1; requestCounter += 1;
return `req-${Date.now()}-${requestCounter}`; return `req-${Date.now()}-${requestCounter}`;
@@ -93,7 +155,9 @@ function renderRecentList() {
<div class="paste-meta"> <div class="paste-meta">
id: ${p.id} | id: ${p.id} |
reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} | reads: ${p.readCount}${p.maxReads ? `/${p.maxReads}` : ''} |
expires: ${msToHuman(p.expiresInMs)} expires: ${msToHuman(p.expiresInMs)} |
format: ${escapeHtml(p.format || 'text')} |
${p.isPublic ? 'public' : (p.encryptionMode || 'encrypted')}
</div> </div>
<div class="paste-actions"> <div class="paste-actions">
<a href="/p/${p.id}" target="_blank" rel="noopener">Open</a> <a href="/p/${p.id}" target="_blank" rel="noopener">Open</a>
@@ -107,11 +171,14 @@ function renderRecentList() {
function handleRealtimePage(data, reset = false) { function handleRealtimePage(data, reset = false) {
const incoming = Array.isArray(data.pastes) ? data.pastes : []; const incoming = Array.isArray(data.pastes) ? data.pastes : [];
const incomingMap = new Map(incoming.map((p) => [p.id, p]));
if (reset) { if (reset) {
allRecent = incoming; allRecent = incoming;
visibleCount = Math.min(PAGE_SIZE, allRecent.length); visibleCount = Math.min(PAGE_SIZE, allRecent.length);
} else { } else {
allRecent = allRecent.concat(incoming); const existing = new Map(allRecent.map((p) => [p.id, p]));
incomingMap.forEach((val, key) => existing.set(key, val));
allRecent = Array.from(existing.values()).sort((a, b) => b.createdAt - a.createdAt);
visibleCount = Math.min(visibleCount + PAGE_SIZE, allRecent.length); visibleCount = Math.min(visibleCount + PAGE_SIZE, allRecent.length);
} }
hasMoreServer = !!data.hasMore; hasMoreServer = !!data.hasMore;
@@ -176,6 +243,7 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
const publicPaste = document.getElementById('publicPaste').checked; const publicPaste = document.getElementById('publicPaste').checked;
const payload = { const payload = {
language: document.getElementById('language').value, language: document.getElementById('language').value,
format: document.getElementById('format').value,
expiresHours: Number(document.getElementById('expiresHours').value), expiresHours: Number(document.getElementById('expiresHours').value),
maxReads: Number(document.getElementById('maxReads').value), maxReads: Number(document.getElementById('maxReads').value),
destroyOnRead: document.getElementById('destroyOnRead').checked, destroyOnRead: document.getElementById('destroyOnRead').checked,
@@ -186,13 +254,39 @@ document.getElementById('create-form').addEventListener('submit', async (e) => {
throw new Error('content is required'); throw new Error('content is required');
} }
let linkSuffix = ''; let linkSuffix = '';
const passphrase = (document.getElementById('passphrase').value || '').trim();
if (publicPaste) { if (publicPaste) {
payload.content = plain; payload.content = plain;
} else { } else {
const encrypted = await encryptContent(plain); const encrypted = passphrase
? await encryptContentWithPassphrase(plain, passphrase)
: await encryptContent(plain);
payload.encrypted = encrypted.encrypted; payload.encrypted = encrypted.encrypted;
linkSuffix = `#k=${encodeURIComponent(encrypted.key)}`; linkSuffix = `#k=${encodeURIComponent(encrypted.key)}`;
if (passphrase) {
payload.encryptionMode = 'passphrase-aes-gcm';
}
} }
const files = document.getElementById('attachments').files;
if (files && files.length > 0) {
const attachments = [];
for (const file of Array.from(files).slice(0, 4)) {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
if (bytes.length > 256 * 1024) continue;
const dataUrl = `data:${file.type || 'application/octet-stream'};base64,${btoa(String.fromCharCode(...bytes))}`;
attachments.push({
id: crypto.randomUUID(),
name: file.name,
mime: file.type || 'application/octet-stream',
size: bytes.length,
data: dataUrl
});
}
payload.attachments = attachments;
}
const msg = await sendWs({ type: 'create-paste', payload }); const msg = await sendWs({ type: 'create-paste', payload });
const data = msg.data; const data = msg.data;
shareLink.value = `${window.location.origin}${data.viewUrl}${linkSuffix}`; shareLink.value = `${window.location.origin}${data.viewUrl}${linkSuffix}`;
@@ -229,9 +323,13 @@ document.getElementById('clear-btn').addEventListener('click', () => {
document.getElementById('maxReads').value = '0'; document.getElementById('maxReads').value = '0';
document.getElementById('destroyOnRead').checked = false; document.getElementById('destroyOnRead').checked = false;
document.getElementById('publicPaste').checked = false; document.getElementById('publicPaste').checked = false;
document.getElementById('passphrase').value = '';
document.getElementById('attachments').value = '';
document.getElementById('format').value = 'text';
updateCreateButtonLabel(); updateCreateButtonLabel();
document.getElementById('content').value = ''; document.getElementById('content').value = '';
document.getElementById('share-link').value = ''; document.getElementById('share-link').value = '';
renderPreview();
setCreateState(''); setCreateState('');
}); });
@@ -269,12 +367,15 @@ function connectWebSocket() {
ws = new WebSocket(wsUrl()); ws = new WebSocket(wsUrl());
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
wsReconnectAttempts = 0;
document.getElementById('live-dot').textContent = 'LIVE'; document.getElementById('live-dot').textContent = 'LIVE';
}); });
ws.addEventListener('close', () => { ws.addEventListener('close', () => {
document.getElementById('live-dot').textContent = 'RECONNECTING'; document.getElementById('live-dot').textContent = 'RECONNECTING';
setTimeout(connectWebSocket, 1200); wsReconnectAttempts += 1;
const backoff = Math.min(10000, 500 * Math.pow(1.6, wsReconnectAttempts));
setTimeout(connectWebSocket, backoff);
}); });
ws.addEventListener('message', (evt) => { ws.addEventListener('message', (evt) => {
@@ -310,5 +411,41 @@ window.addEventListener('scroll', () => {
if (nearBottom) loadNextPage(); if (nearBottom) loadNextPage();
}); });
document.getElementById('toggle-preview').addEventListener('click', () => {
const panel = document.getElementById('preview-panel');
panel.classList.toggle('hidden');
renderPreview();
});
document.getElementById('copy-editor').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(document.getElementById('content').value || '');
setCreateState('Editor text copied.');
} catch {
setCreateState('Could not copy editor text.', true);
}
});
document.getElementById('content').addEventListener('input', renderPreview);
document.getElementById('format').addEventListener('change', renderPreview);
document.getElementById('theme').addEventListener('change', applyTheme);
document.getElementById('content').addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'enter') {
document.getElementById('create-form').requestSubmit();
e.preventDefault();
return;
}
if (e.key === 'Tab') {
const t = e.target;
const start = t.selectionStart;
const end = t.selectionEnd;
t.value = `${t.value.slice(0, start)} ${t.value.slice(end)}`;
t.selectionStart = t.selectionEnd = start + 2;
e.preventDefault();
renderPreview();
}
});
connectWebSocket(); connectWebSocket();
updateCreateButtonLabel(); updateCreateButtonLabel();
applyTheme();
+64
View File
@@ -50,6 +50,21 @@ body {
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.toolbar {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.55rem;
}
.toolbar select {
background: #070d18;
color: #edf3ff;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.45rem 0.6rem;
}
textarea, textarea,
input[type="text"], input[type="text"],
input[type="number"] { input[type="number"] {
@@ -65,6 +80,7 @@ input[type="number"] {
textarea { textarea {
resize: vertical; resize: vertical;
min-height: 240px; min-height: 240px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
} }
textarea:focus, textarea:focus,
@@ -152,6 +168,54 @@ button:hover,
padding-top: 1rem; padding-top: 1rem;
} }
.preview-panel {
margin-top: 0.65rem;
border: 1px solid #334155;
border-radius: 10px;
padding: 0.8rem;
background: #050b16;
}
#preview-content {
color: #dbe7ff;
overflow-x: auto;
}
#preview-content pre {
background: #020617;
padding: 0.8rem;
border-radius: 8px;
border: 1px solid #1e293b;
}
#preview-content code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
body.light {
background: #f7fafc;
color: #0f172a;
}
body.light .panel {
background: #ffffff;
border-color: #cbd5e1;
}
body.light textarea,
body.light input[type="text"],
body.light input[type="number"],
body.light .toolbar select {
background: #ffffff;
color: #0f172a;
border-color: #cbd5e1;
}
body.light .preview-panel {
background: #f8fafc;
border-color: #dbeafe;
}
.result h2 { .result h2 {
margin: 0 0 0.2rem; margin: 0 0 0.2rem;
font-size: 1.02rem; font-size: 1.02rem;
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env node
/*
* peer.paste smoke validation script
* Usage:
* node test-scripts/peer-paste-smoke.js https://peer.paste
*/
const target = process.argv[2] || 'https://peer.paste';
async function run() {
const health = await fetch(`${target}/api/health`).then((r) => r.json());
const stats = await fetch(`${target}/api/stats`).then((r) => r.json());
const openapi = await fetch(`${target}/api/openapi.json`).then((r) => r.json());
if (!health || !health.ok) throw new Error('Health endpoint failed');
if (!stats || typeof stats.total !== 'number') throw new Error('Stats endpoint invalid');
if (!openapi || openapi.openapi !== '3.0.3') throw new Error('OpenAPI endpoint invalid');
const requiredPaths = [
'/api/pastes',
'/api/pastes/{id}/raw',
'/api/pastes/{id}/attachments',
'/api/openapi.json'
];
for (const p of requiredPaths) {
if (!openapi.paths[p]) throw new Error(`Missing path in OpenAPI: ${p}`);
}
console.log('peer.paste smoke checks passed');
}
run().catch((err) => {
console.error(`peer.paste smoke checks failed: ${err.message}`);
process.exit(1);
});