forked from snxraven/p2ns
peer paste updates
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</pre></div>
|
<div class="card">${safePaste.format === 'markdown'
|
||||||
|
? `<div id="md-render">${paste.content.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[m]))}</div>`
|
||||||
|
: `<pre>${paste.content.replace(/[<>&]/g, (m) => ({ '<': '<', '>': '>', '&': '&' }[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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[m]));
|
return String(s).replace(/[<>&"]/g, (m) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user